Merge pull request #1 from logos-co/feat/structural-ingest-gate

feat(contract)!: gate ingest on WHO the caller is, not what it knows
This commit is contained in:
Dario Lipicar
2026-08-26 22:36:14 -03:00
committed by GitHub
parent dbfbf65aef
commit 2370bcb11a
6 changed files with 446 additions and 546 deletions
+54
View File
@@ -0,0 +1,54 @@
name: CI
on:
pull_request:
branches: [master, main]
push:
branches: [master, main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
- name: Setup Cachix
uses: cachix/cachix-action@v15
with:
name: logos-co
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
- name: Build module
run: nix build -L
# `.#unit-tests` is the SAME DERIVATION as checks.<system>.unit-tests
# (verified: identical drvPath), and it RUNS the suite rather than only
# compiling it. That distinction matters here: mkLogosModuleTests finds
# suites with
# find . -maxdepth 1 -executable \( -name "*_tests" -o -name "*_test" \)
# so a binary named anything else is built, matched by nothing, never
# executed -- and the check still goes GREEN. This suite was once named
# `modules_state_invariants` and passed three mutation tests it should
# have caught before that was spotted.
- name: Run unit tests
run: nix build .#unit-tests -L
# Packaging is a separate failure mode from compiling: the .lgx payload
# layout and the install tree are built by different code than the plugin.
- name: Build installable package
run: nix build .#install -L
+66 -19
View File
@@ -10,10 +10,12 @@ first-class, queryable, subscribable fact.
It **reports**. It does not drive load/unload — that stays with liblogos' C API.
## Status: Stage 1
## Status
Nothing feeds it yet. The module is complete and standalone; the liblogos
registry observer and the core push that will feed it are Stages 2 and 3.
The module is complete and standalone. The liblogos **registry observer** that
turns load/unload/crash/discovery into sequenced transitions is merged
(logos-liblogos#189); the **core push** that carries them across this wire is in
progress. Until that lands, nothing feeds this module in a normal run.
## Contract
@@ -29,12 +31,11 @@ type ModuleListing { modules: [ModuleRecord], partial: bool, seq: uint }
method list_modules() -> ModuleListing
method module_record(module: tstr) -> ?ModuleRecord
method is_ready(module: tstr) -> bool
method rejected_ingest_count() -> uint
# ingest surface — authToken-gated, for liblogos core only
method note_transition(authToken, module, instance: ?tstr, pid: ?int,
# ingest surface — admitted only when currentCaller() is the HOST
method note_transition(module, instance: ?tstr, pid: ?int,
old_state, new_state, reason: ?tstr, seq: uint) -> bool
method apply_snapshot(authToken: tstr, listing: ModuleListing) -> bool
method apply_snapshot(listing: ModuleListing) -> bool
event module_state_changed(module, instance: ?tstr, pid: ?int,
old_state, new_state, reason: ?tstr, seq: uint)
@@ -124,22 +125,68 @@ Two traps if you write one yourself:
`note_transition` and `apply_snapshot` write the facts every other module is
about to trust, so the ingest surface is gated while the read surface is open.
The token is an **argument** because liblogos' `AccessPolicy` restricts by target
module with no per-method granularity — restricting `modules_state` to caller
`core` would lock out every reader.
| environment | behaviour |
**The gate is structural: the caller must be the host.**
```cpp
logos::currentCaller().isHost() // logos-cpp-sdk, cpp/logos_caller.h
```
A push from core arrives as `{"kind":"host"}`; a call from any module arrives as
`{"kind":"module","name":…}`. So authority is what the caller **is**, not what it
knows — no secret to distribute, rotate or leak.
`host` carries **no name**, by rule 5 of the caller contract: `core` and
`capability_module` hold the same token value under two keys, so a name there
"would be a coin flip presented as a fact". The gate therefore admits core *or*
capability_module — both host-side components of the runtime rather than peer
modules, which is the distinction that matters here.
| caller | behaviour |
|---|---|
| `LOGOS_MODULES_STATE_INGEST_TOKEN=<nonce>` | ingest requires an exact match. The production shape: the host generates a per-run nonce and puts it in the module subprocess's environment. |
| neither set | **all ingest refused.** Fail closed. |
| `LOGOS_MODULES_STATE_TEST_INGEST=1` (and no token) | **test only.** Any token accepted, with a loud stderr banner on every accepted write. |
| `kind=host` | accepted |
| `kind=module` | **refused**, counted, and named on stderr |
| `kind=unknown` | **refused**, counted. Fail closed. |
| `LOGOS_MODULES_STATE_TEST_INGEST=1` | **test only.** Any caller accepted, with a loud stderr banner. |
The test escape is an environment variable and not a method on purpose: a method
would itself be callable by any module, which would make the gate decorative.
The test door exists because a unit test calls the impl directly — no dispatch,
so no caller, so `Unknown` — and without it these invariants could only be
exercised by driving a live daemon, which is not a thing CI does. It is an
environment variable and not a method on purpose: a method would itself be
callable by any module, which would make the gate decorative.
`rejected_ingest_count()` counts refusals **by the authority gate** and nothing
else — not stale-seq drops, not malformed arguments — so a test asserting "the
gate is closed" asserts exactly that.
### This replaced a shared secret, and why
The ingest surface used to take an `authToken` argument compared against a
per-run nonce in `LOGOS_MODULES_STATE_INGEST_TOKEN`. That design existed only
because the accessor did not — `LogosModuleContext` exposes this module's own
identity and nothing about the caller — and it would have required an `env`
field on `ModuleDescriptor` plumbed through `logos-container` and
`logos-container-subprocess` to deliver the nonce. The accessor landed, it
reaches this module (measured: `kind=host`), and a structural check beats a
secret, so the token and all of that plumbing are retired.
### The one pairing this depends on
This module requires a host that carries the caller machinery, and it ships
alongside one. That pairing is load-bearing rather than incidental.
**A stale `logos-module-builder` pin degrades caller identity to `unknown`
silently.** Measured: at `bc72ce39` the built plugin contained no caller
machinery at all (`nm` finds no `CallerScope`, no `currentInboundCallerJson`)
and every call answered `kind=unknown`; at master `464a75d` the same probe
answers `kind=host`. It compiles, links and loads either way, and nothing warns.
A fail-closed gate on `unknown` then refuses **every** push — inert rather than
secure. There is no counter on this surface to ask about it: the refusal is
written to **stderr**, naming what the caller actually was, with `unknown`
getting its own message pointing at the pin. That log line is the symptom.
One caveat on the measurement: it is macOS. `logos_caller.h` notes that on ELF
at default visibility a function-local static in an inline function emits as
`STB_GNU_UNIQUE` and the linker collapses every image's copy into one, and that
`logos-module-builder` sets no visibility anywhere — so Linux deserves its own
measurement before this is relied on there.
## The replay rule
+159 -270
View File
@@ -1,5 +1,7 @@
#include "modules_state_impl.h"
#include <logos_caller.h>
#include <algorithm>
#include <cstdio>
#include <cstdlib>
@@ -10,88 +12,50 @@
#include <string>
#include <vector>
// ═════════════════════════════════════════════════════════════════════════════
// THREAD SAFETY — read this before touching anything below.
// THREAD SAFETY — read before touching anything below.
//
// Where calls come from
// Inbound method calls arrive through the generated Qt glue. With
// `"concurrency": "single"` in metadata.json the glue dispatches one handler
// at a time, so in THIS build the handlers are already serialised and the
// mutex below is never contended.
// Handlers are serialised today by `"concurrency": "single"`, so the mutex is
// never contended in this build. It exists anyway for two reasons, either
// sufficient: flipping that one metadata key must not silently introduce a data
// race in the module whose job is to be the trustworthy answer about system
// state; and the feed originates on liblogos' background asio thread, so this
// module must not stake its invariants on somebody else's marshalling.
//
// Why the mutex exists anyway — two independent reasons, either sufficient
// 1. `concurrency` is one metadata key. Flipping it to "multi" must not
// silently introduce a data race in the one module whose entire job is to
// be the trustworthy answer about system state. The lock makes that flip
// a performance decision instead of a correctness decision.
// 2. The feed that is coming (liblogos ModuleManager) originates state
// changes on the container's BACKGROUND asio thread — a module crash is
// detected there, not on the main thread. Core is responsible for
// marshalling that onto its Qt queue before it leaves the host, but this
// module must not stake its own invariants on somebody else's marshalling
// being correct.
// THE ONE RULE: never hold m_mutex across an event emission. Emitting crosses
// the C ABI into the host and fans out to subscribers, any of which can call
// straight back in — is_ready() is the obvious one. std::mutex is not
// recursive, so emitting under the lock is a self-deadlock waiting for a
// consumer to be written naturally.
//
// THE ONE RULE THAT MATTERS
// Never hold m_mutex across an event emission.
//
// module_state_changed() is a generated body that marshals into JSON and
// crosses the C ABI into the host, which fans out to every subscriber. A
// subscriber can call straight back in — is_ready() is the obvious one, and
// it is exactly what a consumer reacting to an event would do. std::mutex is
// not recursive, so emitting under the lock is a self-deadlock waiting for a
// consumer to be written naturally.
//
// Every mutator therefore follows the same shape, and there are no
// exceptions to it:
//
// compute under the lock -> collect pending events into a local vector
// -> release the lock -> emit
//
// That is the same compute-under-lock/dispatch-after discipline the liblogos
// registry observer will use on the other side of the wire.
// ═════════════════════════════════════════════════════════════════════════════
// Every mutator therefore follows one shape, with no exceptions:
// compute under the lock -> collect pending events -> release -> emit.
namespace {
// ── The state vocabulary ─────────────────────────────────────────────────────
// Free functions rather than an enum: the wire type is `tstr` (LIDL has no
// enum, no union, no string-literal constraint), and an enum here would invite
// treating an unrecognised state as an error — the single thing the contract
// forbids.
// Free constants rather than an enum: the wire type is `tstr`, and an enum here
// would invite treating an unrecognised state as an error — the one thing the
// contract forbids. They live in this TU because the generator parses the
// header as text, so anything there that is not contract is noise at best.
//
// They live in this TU and not in the impl header on purpose: the generator
// parses the header as text to derive the contract, so anything in it that is
// not part of the contract is at best noise and at worst a dropped declaration.
//
// kAbsent is the EVENT-ONLY one, and the invariant it carries is checkable by
// grep. It is READ where a module leaves the view (note_transition's
// membership edge, and apply_snapshot's skip of a snapshot record that claims
// it), and WRITTEN only into a PendingEvent — as the previousState of a module
// a snapshot has just discovered, and as the new_state of one the snapshot
// pruned. It is never assigned to a stored record's `state`, in any path. That
// absence is the whole of list_modules' never-absent invariant.
// kAbsent is the EVENT-ONLY one, and its invariant is checkable by grep: it is
// READ where a module leaves the view, and WRITTEN only into a PendingEvent.
// It is never assigned to a stored record's `state` on any path.
constexpr const char* kAbsent = "absent";
constexpr const char* kLoaded = "loaded";
constexpr const char* kReady = "ready";
// "Up and usable, as far as the host is concerned."
//
// `loaded` is in this set BECAUSE `ready` is not reachable yet — nothing emits
// it. If the set were {ready} alone, is_ready() would answer false forever and
// the method would be useless on the day it ships.
// `loaded` is NOT in this set: liblogos emits loaded->ready once the module
// publishes its object, so is_ready() is false during that window — which is
// precisely the window a caller is asking about.
//
// When a real loaded->ready transition exists, `loaded` leaves this set. That
// is not a silent break: the only visible change is that is_ready() goes false
// during the loaded-but-not-yet-ready window, which is precisely the window a
// caller of is_ready() was always asking about. Consumers get more correct, not
// broken.
//
// Any state string this build does not recognise answers false — the same
// forward-compatibility fallback the contract imposes on consumers. A module
// that demands others tolerate unknown states has to do it itself.
// An unrecognised state answers false: the same forward-compatibility fallback
// the contract imposes on consumers. A module that demands others tolerate
// unknown states has to do it itself.
bool stateIsReady(const std::string& state)
{
return state == kLoaded || state == kReady;
return state == kReady;
}
// The stored form of a ModuleRecord. Identical to the wire record; kept as its
@@ -110,23 +74,19 @@ struct PendingEvent {
uint64_t seq;
};
// A fully zeroed record: the starting point for a module first learned from a
// delta, whose static metadata (path/type/version/deps) only a snapshot can
// fill in.
// The starting point for a module first learned from a delta, whose static
// metadata only a snapshot can fill in.
//
// It is NOT a value any surface returns, and it deliberately leaves `state`
// EMPTY. The one caller assigns a real state before storing it, and an empty
// state is not in the vocabulary — so if a future path ever stores this seed
// untouched, the result is visibly wrong rather than plausibly "absent". That
// is the whole reason this stopped being called absentRecord(): it used to be
// module_record()'s miss answer, and the miss is std::nullopt now.
// It deliberately leaves `state` EMPTY: the one caller assigns a real state
// before storing, and an empty state is not in the vocabulary, so a future path
// that stored this seed untouched would be visibly wrong rather than plausibly
// "absent".
//
// Written out explicitly rather than leaning on std::map::operator[]'s
// value-initialisation. That would in fact zero the members (ModuleRecord has
// no user-provided constructor), but the guarantee is a language subtlety, and
// the fields cannot carry default member initialisers: the impl header is
// parsed as text to derive the contract, and a `uint64_t seq = 0;` field line
// is a spelling the field scanner is not promised to read.
// Written out rather than leaning on std::map::operator[]'s value-init: that
// would in fact zero the members, but the guarantee is a language subtlety, and
// the fields cannot carry default member initialisers because the header is
// parsed as text and `uint64_t seq = 0;` is not a spelling the field scanner is
// promised to read.
ModuleRecord blankRecord(const std::string& name)
{
ModuleRecord rec;
@@ -138,67 +98,43 @@ ModuleRecord blankRecord(const std::string& name)
// ── INGEST AUTHORITY ─────────────────────────────────────────────────────────
//
// The problem
// note_transition() and apply_snapshot() write the facts every other module
// is about to trust. If any module can call them, any module can forge a
// lifecycle event — announce that a rival crashed, or that a module it wants
// others to call is `ready`. The read surface is deliberately open; the
// ingest surface must not be.
// note_transition and apply_snapshot write the facts every other module is
// about to trust. Unguarded, any module could forge a lifecycle event —
// announce that a rival crashed, or that a module it wants others to call is
// `ready`. The read surface is open; this one must not be.
//
// Why the token is an ARGUMENT and not a policy entry
// liblogos' AccessPolicy restricts by TARGET MODULE, with no per-method
// granularity. Restricting modules_state to caller "core" would lock out
// every reader, which is the entire point of the module. So the authority for
// the two ingest methods has to ride in the call itself.
// THE GATE IS STRUCTURAL: the caller must be the HOST. A push from core arrives
// as {"kind":"host"}; a call from any module arrives as {"kind":"module",...}.
// So authority is what the caller IS, not what it knows, and there is no secret
// to distribute, rotate or leak. This replaced an authToken compared against a
// per-run nonce, which existed only because the accessor did not.
//
// Why this module cannot just check who called it
// LogosModuleContext exposes this module's own identity (moduleName(),
// instanceId(), modulePath()) and nothing about the CALLER. There is no
// caller-identity accessor to check against, so a shared secret is what is
// actually available today. If a caller-identity accessor lands later, this
// gate should be rewritten to use it and the token retired — a structural
// check beats a secret.
// WHAT `host` MEANS: rule 5 of the caller contract gives the host arm no name,
// because "core" and "capability_module" hold the same token value under two
// keys and a name there "would be a coin flip presented as a fact". So this
// admits core OR capability_module — both host-side runtime components rather
// than peer modules, which is the distinction that matters.
//
// The gate, and it is FAIL-CLOSED
// LOGOS_MODULES_STATE_INGEST_TOKEN set and non-empty
// -> ingest requires an exact match. This is the production shape: the
// host generates a per-run nonce, puts it in the module subprocess's
// environment when it spawns it, and sends it with every push. The
// module subprocess's environment is not readable by other modules'
// subprocesses, so the secret does not leak sideways.
// unset
// -> ALL ingest is refused. A build with no configured authority reports
// only what it was given by someone who proved authority, which is
// nothing. That is the correct answer, not an inconvenience.
// unset AND LOGOS_MODULES_STATE_TEST_INGEST=1
// -> the TEST-ONLY escape. Any token is accepted, and the module says so
// loudly on stderr once at first use and again on every accepted call,
// so an accidental production run is visible in the logs rather than
// silent.
// FAIL CLOSED ON `unknown`. currentCaller() answers Unknown for anything that
// is not an inbound dispatch, and for any identity this build cannot parse.
// The case that bites is a MISPINNED BUILD: a stale logos-module-builder
// produces a plugin with no caller machinery at all, so every push is refused
// while everything still compiles, links and loads. This module ships alongside
// a host that carries the machinery, and that pairing is the mitigation — there
// is no counter on this surface to ask. The refusal goes to stderr, naming what
// the caller actually was.
//
// The test escape is deliberately an ENVIRONMENT variable and not a method.
// A method — `enable_test_ingest()` — would itself be callable by any module,
// which would make the gate decorative. Environment is set by whoever launches
// the process, which is the host or the developer, and never by a peer module.
//
// STAGE 1 STATUS: nothing sets LOGOS_MODULES_STATE_INGEST_TOKEN yet, because
// liblogos does not push yet. Every Stage-1 verification run therefore uses
// LOGOS_MODULES_STATE_TEST_INGEST=1, and a run WITHOUT it is the proof that the
// gate is closed by default.
// THE TEST DOOR: LOGOS_MODULES_STATE_TEST_INGEST=1 accepts any caller, loudly.
// A unit test calls the impl directly, so there is no dispatch and no caller,
// and without it these invariants could only be driven through a live daemon —
// which CI does not do. It is an environment variable and not a method on
// purpose: a method would itself be callable by any module.
bool testIngestEnabled()
{
const char* v = std::getenv("LOGOS_MODULES_STATE_TEST_INGEST");
return v != nullptr && std::strcmp(v, "1") == 0;
}
const char* configuredIngestToken()
{
const char* v = std::getenv("LOGOS_MODULES_STATE_INGEST_TOKEN");
if (v == nullptr || v[0] == '\0')
return nullptr;
return v;
}
} // namespace
// ─────────────────────────────────────────────────────────────────────────────
@@ -211,25 +147,16 @@ const char* configuredIngestToken()
struct ModulesStateRegistry {
std::mutex mutex;
// MEMBERSHIP IS THIS MAP. Every module the host's view contains, and
// nothing else. No entry here ever has state "absent" — that state is an
// event-only transition target, so leaving the view means leaving the map.
// MEMBERSHIP IS THIS MAP. No entry ever has state "absent": that is an
// event-only target, so leaving the view means leaving the map.
std::map<std::string, StoredRecord> records;
// Modules that WERE in `records` and are not any more, with the seq at
// which each left. Tombstones, and they exist for exactly one reason: the
// REPLAY RULE has to stay total.
//
// Before `absent` was narrowed, a departed module stayed in `records` as an
// absent record and its `seq` was what a late delta was compared against.
// Now it is erased — so without this table a delta that lost a race would
// find no stored seq, pass the rule, and resurrect a module the host has
// already pruned. This is the same tombstone, minus the fake record: one
// uint64 instead of a whole ModuleRecord, and unreachable from any read
// method, so it cannot be mistaken for membership.
//
// It grows with the number of modules ever seen, not with events. That is
// bounded by what is installed on the machine.
// Tombstones: modules that left `records`, and the seq at which each left.
// They exist so the REPLAY RULE stays total — without them a delta that
// lost a race would find no stored seq, pass the rule, and resurrect a
// module the host already pruned. Unreachable from any read method, so it
// cannot be mistaken for membership. Grows with modules ever seen, not with
// events, so it is bounded by what is installed.
std::map<std::string, uint64_t> departedSeq;
// Highest seq applied from any source. Reported as ModuleListing::seq.
@@ -238,11 +165,6 @@ struct ModulesStateRegistry {
// See list_modules() for why this starts TRUE.
bool partial = true;
// Refusals by the authority gate specifically — NOT stale-seq drops and NOT
// malformed arguments. Kept narrow so a test asserting "the gate is closed"
// asserts exactly that.
uint64_t rejectedIngest = 0;
bool warnedAboutTestIngest = false;
};
@@ -287,24 +209,18 @@ static bool storedSeqLocked(const std::string& module, uint64_t& out)
ModulesStateImpl::ModulesStateImpl() = default;
ModulesStateImpl::~ModulesStateImpl() = default;
// Shared by both ingest methods. Returns true when the caller proved authority;
// increments the refusal counter and complains on stderr when it did not.
static bool ingestAuthorised(const std::string& authToken)
// Shared by both ingest methods. Returns true when the caller IS the host;
// increments the refusal counter and complains on stderr when it is not.
//
// Order matters: the structural check comes first, so a machine that has the
// test door open for some other module's suite still takes the real path here
// whenever the real path can answer.
static bool ingestAuthorised()
{
const char* expected = configuredIngestToken();
const logos::LogosCaller caller = logos::currentCaller();
if (expected != nullptr) {
// Length-independent comparison is not worth it here: the token is a
// per-run nonce and a peer module gets one guess per RPC, not a timing
// oracle. Simplicity beats a false sense of hardening.
if (authToken == expected)
return true;
std::lock_guard<std::mutex> lock(reg().mutex);
++reg().rejectedIngest;
std::fprintf(stderr,
"[modules_state] REFUSED ingest: bad authToken\n");
return false;
}
if (caller.isHost())
return true;
if (testIngestEnabled()) {
bool warn = false;
@@ -316,20 +232,35 @@ static bool ingestAuthorised(const std::string& authToken)
if (warn) {
std::fprintf(stderr,
"[modules_state] *** TEST INGEST ENABLED ***\n"
"[modules_state] LOGOS_MODULES_STATE_TEST_INGEST=1 and no\n"
"[modules_state] LOGOS_MODULES_STATE_INGEST_TOKEN is set, so ANY\n"
"[modules_state] caller can write lifecycle facts. This is for\n"
"[modules_state] testing only. Never set this in production.\n");
"[modules_state] LOGOS_MODULES_STATE_TEST_INGEST=1, so ANY caller\n"
"[modules_state] can write lifecycle facts regardless of identity.\n"
"[modules_state] This is for testing only. Never set it in production.\n");
}
std::fprintf(stderr, "[modules_state] TEST INGEST: accepting unauthenticated write\n");
std::fprintf(stderr, "[modules_state] TEST INGEST: accepting write from a non-host caller\n");
return true;
}
std::lock_guard<std::mutex> lock(reg().mutex);
++reg().rejectedIngest;
std::fprintf(stderr,
"[modules_state] REFUSED ingest: no LOGOS_MODULES_STATE_INGEST_TOKEN is\n"
"[modules_state] configured, so this build accepts no writes at all.\n");
// Refused. The message names what the caller ACTUALLY was, because the two
// ways to land here need different fixes and are otherwise
// indistinguishable from outside:
//
// kind=module a peer module tried to write lifecycle facts. Working as
// intended; this is the case the gate exists for.
// kind=unknown either a non-dispatch context, or A MISPINNED BUILD whose
// plugin carries no caller machinery at all. The second is
// silent everywhere else — it compiles, links and loads —
// so naming it here is the only warning anyone gets.
if (caller.isUnknown()) {
std::fprintf(stderr,
"[modules_state] REFUSED ingest: caller identity is UNKNOWN.\n"
"[modules_state] Either this was not an inbound call, or this build\n"
"[modules_state] carries no caller machinery -- check that\n"
"[modules_state] logos-module-builder is not pinned stale.\n");
} else {
std::fprintf(stderr,
"[modules_state] REFUSED ingest: caller is '%s', not the host.\n",
caller.name.empty() ? "<unnamed non-host>" : caller.name.c_str());
}
return false;
}
@@ -343,27 +274,20 @@ ModuleListing ModulesStateImpl::list_modules()
std::lock_guard<std::mutex> lock(reg().mutex);
out.modules.reserve(reg().records.size());
// std::map iterates in key order, so the listing is sorted by module name.
// Deterministic output is not cosmetic: it is what lets a test diff two
// listings instead of set-comparing them.
// std::map iterates in key order, so the listing is sorted — deterministic
// output lets a test diff two listings instead of set-comparing them.
//
// NOT FILTERED, on purpose. `records` is membership, so every entry belongs
// in the listing and none of them can be "absent" — the two mutators are
// the only writers and neither can store that state. A defensive filter
// here would hide the bug it was written to catch, and would re-introduce
// the second spelling of "not there" that narrowing `absent` removed.
// NOT FILTERED, on purpose: `records` is membership and neither mutator can
// store "absent", so a defensive filter would hide the bug it was written
// to catch.
for (const auto& kv : reg().records)
out.modules.push_back(kv.second);
// `partial` starts TRUE and only a snapshot can clear it.
//
// Before a snapshot has arrived, everything here was learned from
// individual deltas — which by construction only mention modules that
// CHANGED since this module came up. A module that has been quietly loaded
// the whole time is missing from that view. Reporting partial:false then
// would be a confidently short list, which is the exact failure this flag
// exists to prevent. After a snapshot, `partial` is whatever the host said:
// true when the host's own scan skipped a module it could not read.
// `partial` starts TRUE and only a snapshot can clear it. Deltas by
// construction only mention modules that CHANGED, so a module quietly
// loaded the whole time is missing from that view — reporting false would
// be a confidently short list, the exact failure this flag prevents. After
// a snapshot it is whatever the host said.
out.partial = reg().partial;
out.seq = reg().highWaterSeq;
return out;
@@ -375,9 +299,8 @@ std::optional<ModuleRecord> ModulesStateImpl::module_record(const std::string& m
auto it = reg().records.find(module);
if (it != reg().records.end())
return it->second;
// The miss. A tombstone is not a hit: a module that was pruned is exactly
// as absent as one never discovered, and this method's job is to answer
// membership, not history.
// A tombstone is not a hit: a pruned module is exactly as absent as one
// never discovered. This answers membership, not history.
return std::nullopt;
}
@@ -390,31 +313,25 @@ bool ModulesStateImpl::is_ready(const std::string& module)
return stateIsReady(it->second.state);
}
uint64_t ModulesStateImpl::rejected_ingest_count()
{
std::lock_guard<std::mutex> lock(reg().mutex);
return reg().rejectedIngest;
}
// ─────────────────────────────────────────────────────────────────────────────
// Ingest surface
// ─────────────────────────────────────────────────────────────────────────────
bool ModulesStateImpl::note_transition(const std::string& authToken,
const std::string& module,
const std::optional<std::string>& instance,
const std::optional<int64_t>& pid,
const std::string& old_state,
const std::string& new_state,
const std::optional<std::string>& reason,
uint64_t seq)
bool ModulesStateImpl::note_transition(const std::string& module,
const std::optional<std::string>& instance,
const std::optional<int64_t>& pid,
const std::string& old_state,
const std::string& new_state,
const std::optional<std::string>& reason,
uint64_t seq)
{
if (!ingestAuthorised(authToken))
if (!ingestAuthorised())
return false;
// Malformed arguments are refused but NOT counted as an authority refusal:
// rejected_ingest_count() has to mean "the gate turned someone away" and
// nothing else, or it stops being usable as a test assertion.
// Malformed arguments are refused, and are a different thing from the
// authority refusal above: the caller proved it was the host and then sent
// something unusable.
if (module.empty() || old_state.empty() || new_state.empty())
return false;
@@ -428,28 +345,18 @@ bool ModulesStateImpl::note_transition(const std::string& authToken,
{
std::lock_guard<std::mutex> lock(reg().mutex);
// THE REPLAY RULE. Applied if and only if seq is strictly newer than
// what is stored for THIS module.
//
// Delivery is not ordered. The first push to an un-tokened target
// coalesces behind a token handshake, so a later push can complete
// first. Per-module seq makes that harmless with no in-flight buffer,
// no timing assumption and no lock held across an RPC in either
// direction: a stale delta is simply dropped.
// THE REPLAY RULE: applied iff seq is strictly newer than what is
// stored for THIS module. Delivery is not ordered, so a later push can
// land first; per-module seq makes that harmless with no buffering, no
// timing assumption and no lock held across an RPC.
uint64_t stored = 0;
if (storedSeqLocked(module, stored) && seq <= stored)
return false;
// THE MEMBERSHIP EDGE. `absent` is event-only, so a transition INTO it
// does not store an absent record — it removes the module from the
// listing and leaves a seq tombstone behind. The event still fires
// below, carrying the pair the caller computed, and the high-water seq
// still advances, so a consumer re-reading list_modules can still tell
// that something moved.
//
// This is the whole mechanism behind list_modules' never-absent
// invariant: there is no spelling of this method that puts an absent
// record into `records`.
// THE MEMBERSHIP EDGE. A transition INTO `absent` stores no record: it
// removes the module and leaves a seq tombstone. The event still fires
// and the high-water seq still advances. This is the whole mechanism
// behind list_modules' never-absent invariant.
if (new_state == kAbsent) {
// Leaving the view. Erase the record and leave a seq tombstone, so
// a delta that lost a race cannot resurrect a module the host has
@@ -470,29 +377,19 @@ bool ModulesStateImpl::note_transition(const std::string& authToken,
rec.state = new_state;
rec.reason = reason;
rec.seq = seq;
// Two classes of field, treated differently on purpose:
//
// instance / pid are OVERWRITTEN, including to empty. They
// describe the incarnation this transition is ABOUT — an
// unload's pid is the pid that just went away — so a delta is
// authoritative for them and a delta that says "no pid" means
// there is no pid.
//
// path / type / version / dependencies / dependents / loadedAt
// are SNAPSHOT-ONLY. A transition carries the lifecycle change,
// not the module's static metadata, so whatever a previous
// snapshot put there is preserved rather than blanked. A record
// first learned from a delta simply has them empty until a
// snapshot fills them.
// instance/pid are OVERWRITTEN, including to empty: they describe
// the incarnation this transition is ABOUT, so a delta saying "no
// pid" means there is no pid. path/type/version/deps/loadedAt are
// SNAPSHOT-ONLY and preserved rather than blanked — a delta carries
// the lifecycle change, not the static metadata.
}
if (seq > reg().highWaterSeq)
reg().highWaterSeq = seq;
// The pair forwarded is the one the CALLER computed, not
// (ourStoredState -> new_state). Core computes old_state atomically
// with its own write, so its pair is the authoritative description of
// what happened; our stored value can only be older.
// The pair forwarded is the CALLER's, not (ourStoredState -> new).
// Core computes old_state atomically with its own write, so its pair is
// authoritative; our stored value can only be older.
pending = PendingEvent{module, instance, pid, old_state, new_state, reason, seq};
}
@@ -504,10 +401,9 @@ bool ModulesStateImpl::note_transition(const std::string& authToken,
return true;
}
bool ModulesStateImpl::apply_snapshot(const std::string& authToken,
const ModuleListing& listing)
bool ModulesStateImpl::apply_snapshot(const ModuleListing& listing)
{
if (!ingestAuthorised(authToken))
if (!ingestAuthorised())
return false;
std::vector<PendingEvent> pending;
@@ -520,16 +416,11 @@ bool ModulesStateImpl::apply_snapshot(const std::string& authToken,
if (incoming.module.empty())
continue;
// A snapshot record claiming "absent" is contract-malformed: the
// state is event-only, and a snapshot spells non-membership by
// OMISSION. Skipping it makes it mean exactly what omitting it
// would have meant — the prune loop below then drops whatever we
// hold for that name. One rule for leaving the listing, not two.
// An EMPTY state is skipped by the same rule. `state` is tstr on
// the wire with no enum to enforce it, so a record that simply
// omitted the field would otherwise be admitted with state "" —
// a seventh, undeclared state, reachable from outside and
// indistinguishable from a real one to every consumer.
// "absent" is contract-malformed here — a snapshot spells
// non-membership by OMISSION — so skipping makes it mean exactly
// what omitting it would have. An EMPTY state is skipped by the
// same rule: `state` is tstr with no enum, so an omitted field
// would otherwise be admitted as a seventh, undeclared state.
if (incoming.state == kAbsent || incoming.state.empty())
continue;
@@ -546,10 +437,8 @@ bool ModulesStateImpl::apply_snapshot(const std::string& authToken,
if (storedSeqLocked(incoming.module, stored) && incoming.seq <= stored)
continue;
// An unknown module is one the host has just DISCOVERED, whether it
// is unknown because we never heard of it or because it departed
// and came back. Either way the edge it just crossed is
// absent -> incoming.state, which is the membership edge.
// Unknown means just DISCOVERED — never heard of, or departed and
// come back. Either way the edge crossed is absent -> state.
const std::string previousState = known ? it->second.state : std::string(kAbsent);
reg().records[incoming.module] = incoming;
+103 -211
View File
@@ -1,37 +1,23 @@
#pragma once
// ─────────────────────────────────────────────────────────────────────────────
// modules_state — the read-only registry of module lifecycle state.
//
// WHAT THIS IS
// Today there are no module-lifecycle events at all: load, unload and crash
// are spdlog lines inside logos-liblogos' ModuleManager, so every consumer
// polls. This module is the place that state becomes a first-class, queryable,
// subscribable fact.
// Load, unload and crash are spdlog lines inside liblogos' ModuleManager, so
// every consumer polls. This is where that state becomes queryable and
// subscribable. It REPORTS ONLY; load/unload stays with liblogos' C API.
//
// WHAT THIS IS NOT
// It does not drive load/unload. Nothing here starts, stops or restarts a
// module — that stays with liblogos' C API. This module only *reports*.
// read — list_modules / module_record / is_ready + module_state_changed.
// Open to every module.
// ingest — note_transition / apply_snapshot. Host only; see INGEST AUTHORITY
// in the .cpp.
//
// THE TWO SURFACES
// read — list_modules / module_record / is_ready, plus the
// module_state_changed event. Open to every module.
// ingest — note_transition / apply_snapshot. Gated by `authToken`, and
// intended for exactly one caller: liblogos core. See INGEST
// AUTHORITY in modules_state_impl.cpp.
//
// STAGE 1 (this repo, right now)
// Nothing feeds it. The ingest surface is the only way state enters, which
// makes it both the future core seam AND the Stage-1 test injection point.
//
// AUTHORING RULES (universal / Qt-free) — the generator parses this header as
// text, so these are hard constraints, not style:
// AUTHORING RULES (universal/Qt-free). The generator parses this header as
// TEXT, so these are constraints, not style:
// * no Qt types; std + declared structs only.
// * no trailing `// comment` on a declaration line — the parser only accepts
// a line ending in `;`, and silently DROPS anything else. Comments go above.
// * event params that are non-scalar must be `const T&`.
// * do not declare name()/version() auto-injected as `derived`.
// ─────────────────────────────────────────────────────────────────────────────
// * no trailing `// comment` on a declaration line — the parser wants a line
// ending in `;` and silently DROPS anything else. Comments go above.
// * non-scalar event params must be `const T&`.
// * do not declare name()/version(); they are auto-injected as `derived`.
#include <cstdint>
#include <optional>
@@ -40,110 +26,57 @@
#include <logos_module_context.h>
// ─────────────────────────────────────────────────────────────────────────────
// THE STATE VOCABULARY (wire type: tstr — LIDL has no enum)
// THE STATE VOCABULARY (wire type: tstr — LIDL has no enum)
//
// Six RECORD states — the ones a record returned by list_modules or
// module_record may carry:
// Six RECORD states, which a record may carry:
// unloaded known and installed, not running
// loading the host has selected a loader and is bringing it up
// loaded the host owns the process; the object may not be up yet
// ready loaded AND the module has published its object
// stopping an orderly teardown is in progress
// error it exited without being asked to
//
// "unloaded" known and installed, not running
// "loading" the host has selected a loader and is bringing it up
// "loaded" the plugin is up and its provider has published
// "ready" loaded AND has completed its own readiness work
// "stopping" an orderly teardown is in progress
// "error" it exited without being asked to
// One EVENT-ONLY state, which no record ever carries:
// absent the host does not know this module
//
// and one EVENT-ONLY state, which no record ever carries:
// WHY `absent` SURVIVES — do not delete it as redundant. It is the only way to
// name the two MEMBERSHIP EDGES, and those are this module's headline claim:
// `absent -> unloaded` on discovery, `unloaded -> absent` on prune (liblogos
// module_registry.cpp). A transition needs a state on both sides; without it
// those edges cannot be events at all, and a consumer is back to inferring
// membership from package-install events plus a settle timer, which is what
// basecamp's PackageCoordinator does today.
//
// "absent" the host does not know this module
// AND WHY IT IS EVENT-ONLY. A module that is absent is simply NOT IN the
// listing, and module_record answers the empty optional — one spelling for
// "not there", not two. The invariant holds BY CONSTRUCTION: a transition into
// `absent` removes the record, so no spelling of the ingest surface can store
// one.
//
// ── WHY "absent" SURVIVES — DO NOT DELETE IT AS REDUNDANT ────────────────────
// DIVERGENCE FROM THE DRAFT SPECS (logos-lips#317). spec-module-runtime §3.4
// has no `absent`, so we diverge by one event-only transition target, not a
// sixth record state — a consumer reading records sees only spec vocabulary.
// Separately, `loading` is a record state we have and the drafts fold into
// `loaded`; folding is a live option and is not settled here.
//
// It is the only way to name the two MEMBERSHIP EDGES of the graph, and those
// edges are this module's headline claim:
// `ready` is emitted by liblogos when the module publishes its object, which is
// later than `loaded` — the host owning the process. That gap is the window
// is_ready() exists to answer, and is why the read surface says is_ready() and
// not isLoaded().
//
// absent -> unloaded the host has just DISCOVERED a module. In liblogos
// that is the upsert loop of
// discoverInstalledModules (module_registry.cpp:80-94).
// unloaded -> absent the host has just PRUNED one, because its files went
// away (module_registry.cpp:96-108).
//
// A transition needs a state on BOTH sides. Strike `absent` and those two
// edges have no old_state and no new_state to ride on, so they cannot be
// events at all — and a consumer is back to what logos-basecamp does today:
// PackageCoordinator.cpp:117-157 infers module lifecycle from PACKAGE-INSTALL
// events plus a 100ms QTimer settle. Guessing membership from package events is
// exactly what this module exists to stop doing.
//
// ── AND WHY IT IS EVENT-ONLY ─────────────────────────────────────────────────
//
// `absent` may appear as old_state or new_state in module_state_changed. It may
// NEVER be the `state` of a record. A module that is absent is simply NOT IN
// list_modules, and module_record answers an empty optional.
//
// It used to carry a second load: it was also module_record's MISS ANSWER, a
// workaround for a generator that refused `-> ?T`. That refusal is gone, so the
// miss is an empty optional and the second load with it. Two spellings for "not
// there" is the drift this narrowing removes — a consumer that filtered
// `state == "absent"` out of a listing and a consumer that checked has_value()
// were two code paths for one fact, and only one of them stayed correct.
//
// The invariant holds BY CONSTRUCTION, not by convention: a transition into
// `absent` removes the record (see note_transition in the .cpp), so there is no
// spelling of the ingest surface that can put an absent record into the store.
//
// ── DIVERGENCE FROM THE DRAFT CORE SPECS (logos-lips#317) ────────────────────
//
// spec-module-runtime.md §3.4 spells the runtime's vocabulary
// `unloaded | loaded | ready | stopping | error` — five states, no `absent`,
// and it is normative there as a CDDL enum (logos.runtime_control.state).
//
// After the narrowing above, `absent` diverges as exactly one EVENT-ONLY
// TRANSITION TARGET rather than as a sixth record state. A consumer that reads
// records already sees only spec vocabulary; only an event subscriber ever sees
// `absent`, and only on the two membership edges above. The drafts have no way
// to say "discovered" or "pruned" — registry membership is not in their state
// machine at all — so this is a deliberate addition, not drift.
//
// One other divergence, named here so it is not mistaken for the same thing:
// `loading` is a RECORD state we have and the drafts do not. Their `loaded`
// covers our loading+loaded ("Runtime has acquired or attached the selected
// realization and is performing the applicable initialization and readiness
// checks"). Folding the two is a live option; it is a separate question from
// `absent` and is not settled here.
//
// ── REACHABILITY, TODAY ──────────────────────────────────────────────────────
//
// Against liblogos as it stands: absent, unloaded, loading, loaded, stopping
// and error all have real emission points. "ready" does not — it becomes real
// when a module can report its own readiness. It ships anyway so consumers
// written now are not rewritten then; that is the same reason the read surface
// says is_ready() and not isLoaded().
//
// NORMATIVE FORWARD-COMPATIBILITY RULE — this is the whole point of shipping
// the full vocabulary, so it is a rule and not advice:
// A consumer MUST treat an unrecognised state string as forward-compatible
// and fall back to "not loaded". It MUST NOT treat it as an error and MUST
// NOT crash. Without this, the day "ready" starts being emitted is the day
// every existing consumer breaks — the exact failure the full vocabulary
// exists to prevent.
//
// The canonical strings are exposed as functions rather than an enum because
// the wire type is tstr; see modules_state_impl.cpp.
// ─────────────────────────────────────────────────────────────────────────────
// FORWARD-COMPATIBILITY RULE, normative: a consumer MUST treat an unrecognised
// state as forward-compatible and fall back to "not loaded". It must not error
// or crash. Otherwise the day `ready` starts being emitted is the day every
// existing consumer breaks.
// One module's lifecycle facts, as reported by the host.
//
// `state` is one of the six RECORD states. It is never "absent": that one is an
// event-only transition target, and a record for a module that is not there is
// not a record — it is the empty optional module_record answers with.
// `state` is never "absent" — that is event-only, and a record for a module
// that is not there is the empty optional instead.
//
// `instance` and `pid` answer different questions and both are cheap:
// instance — the host's persistence identity. STABLE across load/unload
// cycles (ResolveMode::ReuseOrCreate), so it cannot tell you a
// module died and came back.
// pid — the process incarnation. A changed pid between two reads IS
// that answer.
// `instance` and `pid` answer different questions: instance is the host's
// persistence identity and is STABLE across load/unload cycles, so only a
// changed pid tells you a module died and came back.
struct ModuleRecord {
std::string module;
std::optional<std::string> instance;
@@ -161,13 +94,11 @@ struct ModuleRecord {
// The answer to list_modules().
//
// `partial` is an honest short answer, not a health flag: it is true when the
// host's last scan SKIPPED at least one module (unreadable metadata, or a
// failed trusted-name check). A silently short list is worse than a flagged
// one.
// `partial` is an honest short answer, not a health flag: true when the host's
// last scan SKIPPED a module. A silently short list is worse than a flagged one.
//
// `seq` is the listing-level high-water mark. Paired with each record's own
// `seq` it lets a consumer re-read and tell whether anything moved underneath.
// `seq` is the listing-level high-water mark, so a consumer re-reading can tell
// whether anything moved underneath.
struct ModuleListing {
std::vector<ModuleRecord> modules;
bool partial;
@@ -183,86 +114,55 @@ public:
// Every module the host knows about, with its current state.
//
// MEMBERSHIP IS THE LISTING. No record here ever carries state "absent"
// a module that is absent is not a member, so it is not in `modules` at
// all. A consumer must not filter for it, and a consumer that finds it has
// found a bug in this module, not a module that went away.
// MEMBERSHIP IS THE LISTING: no record here carries "absent". A consumer
// must not filter for it, and one that finds it has found a bug here.
ModuleListing list_modules();
// One module's record — or nothing at all.
//
// EMPTY means "the host's view does not contain this module": never
// discovered, or discovered and since pruned. That is not an error, and it
// is not a record either. `absent` is an EVENT-ONLY state (see THE STATE
// VOCABULARY above), so there is exactly ONE spelling for "not there" and
// this is it.
// EMPTY means the host's view does not contain it: never discovered, or
// discovered and since pruned. Not an error.
//
// Why an optional and not a sentinel record: std::optional has a state no
// ModuleRecord value occupies, so nullopt is distinct from
// ModuleRecord{} — the distinction a bare record could never make. A caller
// branches on has_value(), not on a sentinel field it has to be told to
// look at. (It used to be told: the miss came back as `state:"absent"`,
// `seq:0`. That was a workaround for a generator that refused `-> ?T`, the
// refusal is gone, and the workaround went with it.)
//
// Over the wire the empty answer is JSON null, and on every surface this
// module is reached through, null does NOT mean the call failed:
// * the generated Qt consumer decides success from the C ABI return code
// (rc == LP_OK) and reports failure on a SEPARATE channel,
// logos::CallError — never from the value. See logos-qt-sdk,
// qt-generator/lidl_gen_qt_consumer.cpp:524-558.
// * logoscore's `logosctl module call` answers
// {"status":"ok","result":null}. On a null return it asks the module
// for its published method list and says METHOD_NOT_FOUND only when the
// method genuinely is not there — logos-logoscore-cli,
// src/core_service/call_envelope.cpp:81-103.
// Over the wire that is JSON null, and null does NOT mean the call failed:
// the generated Qt consumer decides success from the C ABI return code and
// reports failure on logos::CallError, never from the value; logoscore
// answers {"status":"ok","result":null} and says METHOD_NOT_FOUND only
// after checking the module's published method list.
std::optional<ModuleRecord> module_record(const std::string& module);
// True when this module is up and usable *from the host's point of view*.
// True when this module is up and usable FROM THE HOST'S POINT OF VIEW.
//
// READ THE LIMIT: this answers "the host has it loaded and it has
// published". It does NOT answer "a call from ME to it will succeed" —
// that additionally needs the per-caller token handshake, which is
// per-caller and therefore not a fact this module can hold. A caller that
// needs "can I call it" wants whenObjectAvailable() on its own client, not
// this. Using is_ready() for that trades a working poll for a predicate
// that goes true a few hundred milliseconds early.
// READ THE LIMIT: it does not answer "a call from ME will succeed" — that
// additionally needs the per-caller token handshake, which is per-caller
// and so not a fact this module can hold. For "can I call it", use
// whenObjectAvailable() on your own client; this predicate goes true a few
// hundred milliseconds early for that purpose.
bool is_ready(const std::string& module);
// ── INGEST SURFACE (authToken-gated; core only) ───────────────────────────
// ── INGEST SURFACE (host only) ───────────────────────────────────────────
// Record one state transition. Returns true when it was applied.
// Record one state transition. True when applied.
//
// Returns FALSE for three different reasons, deliberately not
// distinguished on the wire (a probe must not be able to tell a bad token
// from a stale seq):
// * the token was refused,
// * `seq` was not newer than what is already stored for this module,
// * old_state/new_state were empty.
// FALSE covers three cases, deliberately not distinguished on the wire so a
// probe cannot tell a refused caller from a stale seq: the caller is not the
// host; `seq` was not newer; old_state/new_state were empty.
//
// REPLAY RULE: applied if and only if `seq` is strictly greater than the
// seq already stored for that module. Deliveries are not ordered — the
// first call to an un-tokened target coalesces behind a handshake, so a
// later push can land first — and per-module seq is what makes that
// harmless without any buffering or timing assumption. The rule is TOTAL:
// it covers a module that has since gone absent too, which is why the .cpp
// keeps a seq tombstone for one that left the listing.
// REPLAY RULE: applied iff `seq` is strictly greater than what is stored for
// that module. Delivery is not ordered, so a later push can land first, and
// per-module seq makes that harmless with no buffering and no timing
// assumption. The rule is TOTAL — it covers a module that has since gone
// absent, which is why the .cpp keeps a seq tombstone for one that left.
//
// THE TOMBSTONE PUTS A REQUIREMENT ON CORE, and it is not obvious from
// this side: a record pruned by apply_snapshot is tombstoned at the
// LISTING's seq, so core must stamp snapshot record seqs from the SAME
// global counter it stamps transitions from. Stamp them from anything
// else — a per-snapshot counter, or zero — and the tombstone is either
// unreachably high (a real later delta is dropped forever) or trivially
// low (a stale delta resurrects a module that is gone). One counter, both
// paths.
// THE TOMBSTONE PUTS A REQUIREMENT ON CORE, and it is not visible from this
// side: a record pruned by apply_snapshot is tombstoned at the LISTING's
// seq, so core must stamp snapshot record seqs from the SAME counter it
// stamps transitions from. Anything else makes the tombstone unreachably
// high (a real later delta dropped forever) or trivially low (a stale delta
// resurrects a pruned module).
//
// A transition whose `new_state` is "absent" is a MEMBERSHIP EDGE. The
// event fires exactly as any other, but no absent record is stored: the
// module leaves the listing instead. That is what makes list_modules'
// never-absent invariant true by construction.
bool note_transition(const std::string& authToken,
const std::string& module,
// `new_state == "absent"` is a MEMBERSHIP EDGE: the event fires as normal
// but the module leaves the listing rather than being stored absent.
bool note_transition(const std::string& module,
const std::optional<std::string>& instance,
const std::optional<int64_t>& pid,
const std::string& old_state,
@@ -272,32 +172,24 @@ public:
// Replace the whole picture with a host-supplied snapshot.
//
// This is what makes a late-loading modules_state correct: core pushes a
// snapshot when this module comes up, so everything that loaded BEFORE it
// (capability_module always does) is still reported.
// This is what makes a late-loading modules_state correct: it loads after
// other modules, so deltas alone give it a permanently short list.
//
// Merge is per-record and uses the same seq rule as note_transition, so a
// delta that overtook the snapshot survives it. Records missing from the
// listing whose stored seq is older than the listing's seq are dropped,
// each with its own state -> "absent" event.
// Merge is per-record under the same seq rule, so a delta that overtook the
// snapshot survives it. Records missing from the listing whose stored seq is
// older than the listing's are dropped, each with its own "absent" event.
//
// A snapshot record whose `state` is "absent" is contract-malformed —
// `absent` is event-only, and a snapshot spells non-membership by OMISSION.
// It is skipped, which makes it mean exactly what omitting it would have
// meant. One rule, not two.
bool apply_snapshot(const std::string& authToken, const ModuleListing& listing);
// Number of ingest calls refused. A counter, not an event: it exists so a
// test can prove the gate is closed, and so an operator can see a module
// trying to forge lifecycle facts.
uint64_t rejected_ingest_count();
// A snapshot record claiming "absent" is contract-malformed — a snapshot
// spells non-membership by OMISSION — so it is skipped, which makes it mean
// exactly what omitting it would have meant.
bool apply_snapshot(const ModuleListing& listing);
logos_events:
// Emitted on every APPLIED transition including the ones apply_snapshot
// Emitted on every APPLIED transition, including those apply_snapshot
// applies.
//
// A transition PAIR, not a "kind" string: strictly more informative, and a
// consumer that only cares that something went away just reads new_state.
// A transition PAIR rather than a "kind" string: strictly more informative,
// and a consumer that only cares something went away reads new_state.
// old_state is never equal to new_state; a no-op is not an event.
void module_state_changed(const std::string& module,
const std::optional<std::string>& instance,
+5 -9
View File
@@ -3,17 +3,13 @@ project(ModulesStateTests LANGUAGES CXX)
include(LogosTest)
# The NAME must end in _test or _tests. mkLogosModuleTests runs the suite with
# NAME must end in _test or _tests. mkLogosModuleTests finds suites with
# find . -maxdepth 1 -executable \( -name "*_tests" -o -name "*_test" \)
# and pipes the result into a while-read loop, so a binary named anything else
# is BUILT, MATCHED BY NOTHING, and never executed — and the check still goes
# green, because compiling was all that happened. This suite was written as
# `modules_state_invariants` and passed three mutation tests it should have
# caught before that was spotted.
# so a binary named anything else is BUILT, MATCHED BY NOTHING, never executed —
# and the check still goes GREEN. This suite was `modules_state_invariants` and
# passed three mutation tests it should have caught before that was spotted.
# The registry logic only no daemon, no Qt, no plugin load. These are the
# invariants that were previously checked only by driving logoscore by hand,
# which CI does not do, so they were unguarded in practice.
# Registry logic only: no daemon, no Qt, no plugin load.
logos_test(
NAME modules_state_invariants_tests
MODULE_SOURCES
+59 -37
View File
@@ -1,12 +1,6 @@
// The three invariants that are cheap to state and expensive to lose.
//
// Each is load-bearing for a claim the header makes, and each was previously
// checked only by driving a live logoscore daemon by hand — which is not a
// thing CI does, so in practice they were unguarded.
//
// The ingest gate is opened here with LOGOS_MODULES_STATE_TEST_INGEST=1, which
// is the documented test-only door. A run WITHOUT it is the proof that the gate
// is closed by default; that belongs in its own case, below.
// The invariants that are cheap to state and expensive to lose. Each is
// load-bearing for a claim the header makes, and each was previously checked
// only by driving a live daemon by hand — which CI does not do.
#include <logos_test.h>
#include "../src/modules_state_impl.h"
@@ -15,10 +9,9 @@
#include <string>
#include <vector>
// The event emitter is DECLARED on the impl and DEFINED by the generated
// scaffold, which a unit test does not link. Stubbing it here is not a
// workaround — it is what makes the emissions assertable at all, so the
// membership edges can be checked rather than assumed.
// The emitter is declared on the impl and defined by the generated scaffold,
// which a unit test does not link. Stubbing it is what makes the emissions
// assertable, so the membership edges are checked rather than assumed.
std::vector<std::string> g_emitted;
void ModulesStateImpl::module_state_changed(const std::string& module,
@@ -41,12 +34,13 @@ struct OpenIngest {
~OpenIngest() { unsetenv("LOGOS_MODULES_STATE_TEST_INGEST"); }
};
constexpr const char* kTok = "test-ingest";
// A unit test calls the impl DIRECTLY: no dispatch, so no caller, so
// currentCaller() answers Unknown and the real gate refuses. The test door is
// what makes these checkable in CI; the closed-gate case below opens none.
bool note(ModulesStateImpl& m, const std::string& mod,
const std::string& from, const std::string& to, uint64_t seq)
{
return m.note_transition(kTok, mod, std::nullopt, std::nullopt,
return m.note_transition(mod, std::nullopt, std::nullopt,
from, to, std::nullopt, seq);
}
@@ -59,12 +53,9 @@ bool listingHas(const ModuleListing& l, const std::string& mod)
} // namespace
// INVARIANT 1 — no surface may ever hand back a record whose state is "absent".
//
// `absent` is an EVENT-ONLY transition target. If a record could carry it,
// there would be two spellings for "not there" — a record saying absent, and
// the empty optional — and every consumer would have to handle both or quietly
// handle one.
// INVARIANT 1 — no surface hands back a record whose state is "absent". If one
// could, there would be two spellings for "not there" and every consumer would
// have to handle both, or quietly handle one.
LOGOS_TEST(absent_never_appears_on_a_record)
{
OpenIngest gate;
@@ -95,12 +86,10 @@ LOGOS_TEST(absent_never_appears_on_a_record)
LOGOS_ASSERT(sawOut);
}
// INVARIANT 2 — a departed module stays departed under a stale delta.
//
// Erasing the record also erases the seq the replay rule compares against, so
// without a tombstone an out-of-order push for a pruned module would resurrect
// it. Deliveries are NOT ordered, so this is a real sequence, not a contrived
// one.
// INVARIANT 2 — a departed module stays departed under a stale delta. Erasing
// the record also erases the seq the replay rule compares against, so without a
// tombstone an out-of-order push would resurrect it. Delivery is not ordered,
// so this is a real sequence, not a contrived one.
LOGOS_TEST(a_stale_delta_does_not_resurrect_a_departed_module)
{
OpenIngest gate;
@@ -121,11 +110,8 @@ LOGOS_TEST(a_stale_delta_does_not_resurrect_a_departed_module)
}
// INVARIANT 3 — a snapshot record with no state is skipped, like an absent one.
//
// `state` is tstr on the wire with no enum behind it, so a record that simply
// omitted the field would otherwise be admitted with state "" — a seventh,
// undeclared state, reachable from outside and indistinguishable from a real
// one to every consumer.
// `state` is tstr with no enum, so an omitted field would otherwise be admitted
// as a seventh, undeclared state indistinguishable from a real one.
LOGOS_TEST(a_snapshot_record_with_no_state_is_not_admitted)
{
OpenIngest gate;
@@ -145,20 +131,56 @@ LOGOS_TEST(a_snapshot_record_with_no_state_is_not_admitted)
listing.modules = { stateless, real };
listing.seq = 5;
LOGOS_ASSERT(m.apply_snapshot(kTok, listing));
LOGOS_ASSERT(m.apply_snapshot(listing));
LOGOS_ASSERT(!m.module_record("ghost_module").has_value());
LOGOS_ASSERT(m.module_record("irc_module").has_value());
}
// The gate is closed unless a door is opened, and this case deliberately opens
// none. It is the reason the others may open one without weakening the claim.
// none. With no dispatch the caller is Unknown, which is NOT the host, so the
// structural gate refuses — the same answer a peer module gets.
LOGOS_TEST(ingest_is_refused_when_no_door_is_open)
{
unsetenv("LOGOS_MODULES_STATE_TEST_INGEST");
unsetenv("LOGOS_MODULES_STATE_INGEST_TOKEN");
ModulesStateImpl m;
LOGOS_ASSERT(!note(m, "chat_module", "absent", "unloaded", 1));
LOGOS_ASSERT(!m.module_record("chat_module").has_value());
LOGOS_ASSERT(m.rejected_ingest_count() >= 1);
// ...and it is absent from the listing too. NOT modules.empty(): the
// registry is a process-wide static, so earlier cases in this binary have
// left records behind.
LOGOS_ASSERT(!listingHas(m.list_modules(), "chat_module"));
}
// `loaded` is not `ready`. liblogos marks a module loaded when it owns the
// process, and emits loaded->ready only once the module publishes its object.
// is_ready() must answer false in that window — it is the window callers ask
// about — and an unknown state must not be optimistic either.
LOGOS_TEST(ready_is_publish_not_load)
{
OpenIngest gate;
ModulesStateImpl m;
LOGOS_ASSERT(note(m, "eth_rpc_module", "absent", "unloaded", 1));
LOGOS_ASSERT(!m.is_ready("eth_rpc_module"));
LOGOS_ASSERT(note(m, "eth_rpc_module", "unloaded", "loading", 2));
LOGOS_ASSERT(!m.is_ready("eth_rpc_module"));
// The window: the host owns the process, the object is not up yet.
LOGOS_ASSERT(note(m, "eth_rpc_module", "loading", "loaded", 3));
LOGOS_ASSERT(!m.is_ready("eth_rpc_module"));
LOGOS_ASSERT(note(m, "eth_rpc_module", "loaded", "ready", 4));
LOGOS_ASSERT(m.is_ready("eth_rpc_module"));
// Going away closes it again.
LOGOS_ASSERT(note(m, "eth_rpc_module", "ready", "stopping", 5));
LOGOS_ASSERT(!m.is_ready("eth_rpc_module"));
// A module nobody reported is not ready, and neither is an unrecognised
// state — forward compatibility fails closed.
LOGOS_ASSERT(!m.is_ready("never_seen_module"));
LOGOS_ASSERT(note(m, "eth_rpc_module", "stopping", "quiescing", 6));
LOGOS_ASSERT(!m.is_ready("eth_rpc_module"));
}