Commit Graph
85 Commits
Author SHA1 Message Date
jochen de4b63824e node-tools provides its endpoint at node scope, so a consumer on the machine is told the console's port rather than writing it (hq to-be 40 WP1) 2026-10-03 16:02:08 +02:00
mesh-admin 8e30ea9ff5 Merge pull request 'node-tools hands each bundle its own environment (hq ADR 0192)' (#33) from feat/0192-each-bundle-its-own-env into main 2026-10-03 13:34:05 +00:00
jochen 193ed0ac63 node-tools hands each bundle its own environment (hq ADR 0192)
The mesh composes every served module's words into MESH_TOOL_ENV; the runtime takes it at start
and removes it from its own environment, then gives each registration's contributor and each
launched child the runtime's words plus its own module's, never another's. Against an SDK
without collectToolsEach it says so and serves with the runtime's words only. The test serves
two imported bundles and one launched, each answering with its own words and none of the others'.
2026-10-03 15:28:49 +02:00
mesh-admin 4537494150 Merge pull request 'One SDK per runtime: a bundle's SDK import resolves to the runtime's copy (hq issue 209)' (#32) from fix/issue-209-one-sdk-per-runtime into main 2026-10-03 11:07:08 +00:00
jochen 7152148410 One SDK per runtime: a bundle's SDK import resolves to the runtime's copy (hq issue 209)
A bundle carries its dependencies, the SDK among them; imported in-process that copy was a
second SDK with its own tool registry, so a bundle registered its tools into a list the
runtime never read and served nothing, silently. A resolve hook (module.registerHooks, in
thread; module.register is deprecated from Node 26) now sends every import of
@novox/mesh-sdk, from whichever bundle, to the runtime's own copy: one registry, one broker.
The test loads a bundle from a directory holding its own SDK copy and sees its tool served.
2026-10-03 13:00:19 +02:00
mesh-admin 5d488a45f7 Merge pull request 'node-tools is a module beside mesh-tools: the runtime as a bundle, and serve is the console (hq ADR 0175, to-be 38 WP3)' (#31) from feat/wp3-node-tools into main 2026-10-02 19:57:24 +00:00
jochen c46f9502ee node-tools is a module beside mesh-tools: the runtime as a bundle, and serve is the console (hq ADR 0175, to-be 38 WP3)
One repository, two modules (ADR 0069). `node-tools/` holds the runtime — its code, tests, package
and the manifest of the module the controller composes a process for on every machine it is
assigned to: a bundle of `src/main.js`, the interpreter as a package, a place for the node's
credential, the loopback port the console declared, and leave to call every tool. Nothing about
how it runs: which bundles to load, where the credential is and whose machine it is are the
controller's to compose (WP2). The root module `mesh-tools` keeps the two images TypeScript
bundles are compiled in and a module's own service may run in; it is no longer how tools reach a
node.

As node-tools, `serve` is also the console (ADR 0175 §6): the same process answers MCP on
loopback for whoever is on the machine, through which the tools it serves can be called. A
module's own runtime in a container keeps serving without a listener.

The toolchain image now carries /app/runtime — a package.json saying the compiled files are ES
modules and the production node_modules — for the builder to copy into every TypeScript bundle,
so a bundle unpacked on a machine starts (ADR 0188 §5; the builder's side is the controller's).
Proven here by compiling node-tools with the toolchain's exact flags and starting the result.
The AMQP probe script is gone with the bus it probed.
2026-10-02 21:43:37 +02:00
mesh-admin 2746fd31f0 Merge pull request 'Cite ADR 0188, not 0187: the record was renumbered before it merged' (#30) from fix/cite-adr-0188 into main 2026-10-02 19:29:30 +00:00
jochen 82d7306ee0 Cite ADR 0188, not 0187: the record was renumbered before it merged (0187 is the dead-tracker record) 2026-10-02 21:29:04 +02:00
mesh-admin 2818f99b17 Merge pull request 'The runtime serves a list of modules on one credential, naming a bundle that fails to load (hq ADR 0175, to-be 38 WP1)' (#29) from feat/the-operators-machine into main 2026-10-02 19:27:49 +00:00
jochen 1436b02755 A bundle that is not JavaScript is launched and spoken to over MCP on stdio (hq ADR 0187, to-be 38 WP1b)
The runtime imported a bundle into its own process, which only JavaScript can be. Now an entrypoint
that is not a plain JavaScript file — or is one marked executable — is started as a child with the
runtime's environment and asked `tools/list` once and `tools/call` per call; what it lists is
registered exactly as an imported bundle's registrations are, a `<seat>.<verb>` name as the seat's
implementation. So a tools bundle may be in any language, and the mesh's part — the subjects, the
seats, the `tools` answer, a failed bundle named — stays in the runtime and is shared by all of
them. A child that exits mid-call tells the caller so and is started again on its next call.

Proven against a real bus beside the three bundles already there: a Python bundle with no SDK at
all answers its tool and its seat verb; a TypeScript bundle written against the protocol and marked
executable is served through the launcher, shortcut off; a bundle told to exit is relaunched.
2026-10-02 21:24:20 +02:00
mesh-admin 4e0559c31a Merge pull request 'Cite hq ADR 0170, not 0169: the firewall seat's record was renumbered' (#28) from fix/adr-0170-cited into main 2026-10-02 16:44:07 +00:00
jochen 6390d1d7fb The runtime serves a list of modules on one credential, naming a bundle that fails to load (hq ADR 0175, to-be 38 WP1)
One tool runtime per node, host-side, is what the runtime was written to be; the catalogue built a
container per module around it instead. This lets `serve` take a list — MESH_TOOL_MODULES as
<module>=<entrypoint> entries — and do for every assigned module what it did for one: read that
module's membership and follow it, serve its tools where the membership says, serve each held
seat's verbs on the seat's subjects. The seats come from the memberships now, so the node's
credential carries no claims; a module's own runtime still reads its credential's, so nothing
built today changes behaviour. A bare path in MESH_TOOL_MODULES stays the one-module form.

A bundle that throws on import is said in the log and in what `tools` answers for its module
(`failed`), which discovery lists with the reason instead of as "not answering"; the other bundles
serve. The filter that dropped every registration under a name but the one module goes; what
stays is that a registration under a seat's name is served only where some served module claims
the seat. A tool runs attributed to its module, so an event it emits lands on the module's
subject and not the runtime's. MESH_OPERATOR_ACCOUNT and MESH_OPERATOR_HOME are read and said;
tools take them from their environment.

Proven against a real bus: three bundles, one broken; five tools and two seat verbs answer on
their subjects; `tools` names the failed bundle; a membership re-issued mid-run re-serves.
2026-10-02 18:22:30 +02:00
jschoubben 020003ea3f Cite hq ADR 0170, not 0169: the firewall seat's record was renumbered after a collision on hq main 2026-10-02 14:52:28 +02:00
mesh-admin 809f07d085 Merge pull request 'A node-scoped seat's verb is callable through the console, naming its machine (hq ADR 0169)' (#27) from fix/a-node-scoped-seats-verb-is-callable-through-the-console into main 2026-10-02 12:24:02 +00:00
jschoubben fb0dcd0cc1 A node-scoped seat's verb is callable through the console, naming its machine (hq ADR 0169)
The listing left node-scoped seats out, so <seat>.<verb> never resolved as a
seat's and the call went to a module subject nothing served. Listed now with
their scope: the schema requires the machine, the call carries it in the
subject, and a call without one is refused in words.
2026-10-02 14:23:26 +02:00
mesh-admin a3ace58362 Merge pull request 'A registration under a stranger's name is said and skipped, not fatal' (#26) from fix/a-registration-under-a-strangers-name-is-refused-not-fatal into main 2026-10-01 14:47:31 +00:00
jschoubben feb13c1de6 A registration under a stranger's name is said and skipped, not fatal
A module implementing a seat its credential does not (yet) claim registered tools under the seat's
name; the runtime treated them as the module's own, refused to serve another module's key, and the
whole runtime restarted in a loop — postgres on the control node, 2026-10-01, whose credential predated
the claims. Such a registration is now named in the log and left out; the module's own tools serve.
2026-10-01 16:47:03 +02:00
mesh-admin e6676068c7 Merge pull request 'The membership is read by its subject' (#25) from fix/the-membership-is-read-by-its-subject into main 2026-10-01 14:22:26 +00:00
jschoubben a01b5f5b78 The membership is read by its subject
The runtime asked the assignments stream's root for the last message by subject in the body, and the
mesh grants a module's account only the subject-addressed form of the direct get — the server refused
every read (Publish Violation on $JS.API.DIRECT.GET.ASSIGNMENTS for every module on the new runtime),
so every runtime kept the derived shape. The address is now the stream then the subject, nothing in the
body, which is the one address the account has.
2026-10-01 16:16:57 +02:00
mesh-admin ab8b13f512 Merge pull request 'A seat's verb keeps a node of its own; only a module's tool gives it to the subject' (#24) from fix/a-seat-verbs-node-is-its-own into main 2026-10-01 13:44:22 +00:00
jschoubben ce8c37a7fc The listing test tells the seat's two verbs apart: one keeps its own node, the other takes none 2026-10-01 15:43:36 +02:00
jschoubben 809e2b11ec The listing test indexes the module's tool after the seat's two verbs 2026-10-01 15:42:58 +02:00
jschoubben 670b486ac0 A seat's verb keeps a node of its own; only a module's tool gives it to the subject
The console moved every call's node into the subject (ADR 0159), so mesh-controller.push {node: x}
became a call to the seat's verb on machine x, which nothing serves — the mesh's own verbs could not
be given a machine from the console at all. A role's verb takes no machine from the console; its
arguments are its own.
2026-10-01 15:42:35 +02:00
mesh-admin 56f82588af Merge pull request 'A runtime serves what the mesh issued it, and a seat's verbs are implemented under the seat's name (hq ADR 0160)' (#23) from feat/a-runtime-serves-what-it-is-issued into main 2026-10-01 13:23:55 +00:00
jschoubben e7b98f1fbc A runtime serves what the mesh issued it, and a seat's verbs are implemented under the seat's name (hq ADR 0160)
The one address a runtime derives for itself is mesh.assignment.<node>.<module>. It reads the
membership there with a direct get on the ASSIGNMENTS stream, serves each tool exactly where the
membership says — the plain subject in the module's queue when the mesh issued one, this machine's
beside it — and follows the subject, re-serving when a new membership arrives. A mesh that has issued
nothing yet gets the shape it always derived, and the log says so.

A seat's verbs are the role's, not the software's (ADR 0159): a module implements them with
registerModuleTools("<seat>", …), the runtime serves that on the seat's subjects when the credential
claims the seat, and never lists it among the module's own tools. A module named like its seat
registers once and is both.

The tools answer carries each tool's subjects, and the console and CLI call the subject the listing
gave them instead of composing one.
2026-10-01 15:17:14 +02:00
mesh-admin 278a25b3e5 Merge pull request 'A tool call names the machine it is for, every answer says which machine answered, and a holder's runtime serves its seat's verbs (ADR 0159)' (#22) from feat/a-tool-call-names-the-machine into main 2026-10-01 12:01:40 +00:00
jschoubben c65f1993ba A tool call names the machine it is for, every answer says which machine answered, and a holder's
runtime serves its seat's verbs (novox/hq ADR 0159)

A module on several machines served one subject in one queue group, so a call reached whichever
instance answered first and nobody could ask one machine's instance. Now each instance also serves
its subject with its machine as the last token, `<module>.<tool>@<node>` addresses it, and every
answer carries the machine that gave it: the console lists `node` on every module tool, strips it
into the subject, and appends "answered by <node>" to the answer; `mesh call` prints it.

And a seat's verbs are served by whoever claims the seat: the credential names the claimed seats
and the verbs each promises, the runtime serves each verb with the module's tool of the same name
on the seat's own subject, and the bus — which admits only the holder's subscription — decides where
that serving is real. Design 33 §3 and §4, built.
2026-10-01 13:58:22 +02:00
jschoubben 6a91d144b3 Merge pull request 'The console lists and calls a role's tools' (#21) from feat/the-mesh-answers-for-itself into main
Reviewed-on: #21
2026-09-30 15:54:31 +00:00
jschoubben 71965ef958 The console lists and calls a role's tools
seat:<seat>.<verb> addresses a role's tool (with @<node> for a node-scoped seat); the listing asks the
mesh-controller seat's tools verb beside the modules and marks a role's tools; <seat>.<verb> resolves
to the seat when the seat declares that verb, a module's own name otherwise (novox/hq ADR 0154).
2026-09-30 17:41:56 +02:00
jschoubben dea98e509a Merge pull request 'The console: mesh serve on loopback, and every runtime answers tools' (#20) from feat/the-console into main
Reviewed-on: #20
2026-09-30 14:46:54 +00:00
jschoubben 80b02740ab The console: mesh serve on loopback, and every runtime answers tools
The runtime serves a tools verb per module with names, descriptions and schemas (design 34 §3), and
refuses a module naming its own tool tools. Discovery asks catalog_modules then each module, naming
what did not answer. One MCP handler over two transports: stdio (mesh mcp) and loopback HTTP (mesh
serve, the mesh-console module, novox/hq ADR 0152); serve refuses any bind but loopback. tools/call
may go through a running console with --console and no credential.
2026-09-30 16:19:22 +02:00
mesh-admin 621d033d53 Merge pull request 'One consumer, one reader, however many patterns a module registers' (#19) from fix/one-consumer-one-loop into main 2026-09-28 14:25:47 +00:00
jschoubben 38831c5c56 One consumer, one reader, however many patterns a module registers
A module has exactly one durable consumer, and each subscribe() started its own reader of it. Two
readers split the stream between them, and a reader that receives a message its own pattern does not
match acknowledges it — which is the right answer for a filter wider than anything registered, and
silent loss when the message was another handler's. The first module to subscribe twice would have
dropped roughly half of each kind of event with nothing reporting it.

Every registration is now dispatched from one reader, and a message is acknowledged once every handler
it is for has taken it.
2026-09-28 16:25:42 +02:00
mesh-admin fdad2f3268 Merge pull request 'A module answers the word the mesh asks: prepare' (#18) from feat/a-module-answers-prepare into main 2026-09-28 10:45:02 +00:00
jschoubben 81972a4995 A module answers the word the mesh asks: prepare
The runtime gains `prepare`, which brings this module's state to the shape this version needs and
exits (novox/hq ADR 0135). The entrypoints come from MESH_PREPARE, which a module's own image names
beside the entrypoints it already lists there — the module knows which of its files prepares its
state and nothing else could. No broker is connected: preparation runs before the version that would
use it. An empty list fails rather than passing quietly, because the mesh asks this only of a module
whose manifest says it prepares something, and exiting 0 would let that version serve against a
state nobody shaped.
2026-09-28 12:45:00 +02:00
mesh-admin 10e8191717 Merge pull request 'The runtime hears on its own inbox' (#17) from fix/the-runtime-hears-on-its-own-inbox into main 2026-09-28 02:30:24 +00:00
jschoubben d703cebff4 The runtime hears on its own inbox
Every user's inbox is private to it and the grant names it; a reply space the client invented was
refused, and with it every pull for the next message and every answer to a tool call.
2026-09-28 04:30:22 +02:00
mesh-admin 46b56d53a6 Merge pull request 'The pin is the only check: the runtime stops verifying the bus's name' (#16) from fix/the-pin-is-the-only-check into main 2026-09-28 02:03:46 +00:00
jschoubben d4a2802342 The pin is the only check: the runtime stops verifying the bus's name
Every module on the new runtime reached the handshake and failed on 'does not match
certificate's altnames': the bus's certificate names the seat, not the address a machine dials
it by, and pinning the exact certificate already decides everything a name check could. The
client's transport spreads the TLS options into Node's tls.connect, so the hostname check is
replaced with one that passes and the pinned certificate is the one authority accepted.
2026-09-28 04:03:43 +02:00
mesh-admin 8acfa7a07d Merge pull request 'One bus: the runtime pins the certificate after the server speaks, and the old transport goes' (#15) from feat/one-bus into main 2026-09-28 01:09:01 +00:00
jschoubben f0104b7846 One bus: the runtime pins the certificate after the server speaks, and the old transport goes
Every module that dialled the new bus failed its handshake with "wrong version
number": the runtime pinned the server's certificate by a raw TLS connection to a
port on which the server speaks first, in the clear. The pin is taken after the
INFO line now, on the same socket, and then the real connection verifies against
exactly that certificate.

And the old transport is deleted — its client, its tests, its dependency — with
the wire-compatibility pins that only existed for the move (novox/hq ADR 0131,
design 28 task 5.5). A credential names the bus, and there is one.
2026-09-28 03:08:59 +02:00
mesh-admin cd26131c61 Merge pull request 'The runtime speaks the bus its credential names' (#14) from feat/the-runtime-speaks-the-bus-its-credential-names into main 2026-09-28 00:42:29 +00:00
jschoubben 9e3ff6fa45 The runtime speaks the bus its credential names
A module moved to the bus being built was handed a credential for it — nats://
with user, password and fingerprint beside the address — and nothing else in its
environment changed. The runtime always dialled the old bus, so every moved module
kept serving and answered nobody. The scheme in the credential is enough to know
which bus to speak; the nats broker was already written and never chosen.
2026-09-28 02:42:26 +02:00
jschoubben a4447f1251 Merge pull request 'A person's own client, and pins that the wire did not change' (#13) from feat/nats-genesis into main 2026-09-27 17:20:07 +00:00
jschoubben 3d55aeb1d8 The old bus's wire is pinned unchanged, because this has to merge to a running mesh
Every module's event names were converted from the old bus's routing keys to local
names, and this client maps them back. If that mapping is wrong anywhere a live mesh's
events stop being delivered — silently, because a binding that matches nothing is not an
error.

So the mapping is pinned against the literal routing keys the mesh published before,
taken from the manifests as they were: what each module now emits, what each now binds,
and that a handler still matches what the bus delivers. Including the audit logger's
"everything", which must stay `#` on this bus.

And a key already in the old form is left alone, so a module built from an older manifest
keeps working beside one built from a current manifest — which is the state the mesh will
actually be in between deployments.
2026-09-27 17:37:19 +02:00
jschoubben 9acc40145a A person's client: the mesh's tools from a workstation
Design 25 §7's second item. Two surfaces over one thing — a command line for somebody
at a terminal, an MCP server for an agent — and both are adapters over the same three
calls: what tools are there, what does this one take, call it. A second way of reaching
a tool would be a second thing to keep correct.

It uses the client a module's runtime uses. Not a bridge and not a second protocol: a
person connects as their own bus user and publishes on the tool subjects their account
permits, so "what may this person do" is answered by the same permission list that
answers it for a module, and an audit has nothing separate to read.

`mesh tools` lists what the *catalogue* has, not what this credential may call. The two
differ and the difference is the point: somebody seeing only their own tools cannot tell
"not installed" from "not yours", and those need different people to fix them.

A failed call says which of three things happened, because the remedies are in three
different places: nobody serves that tool, this credential may not call it, or the tool
itself was slow. Without that they are one timeout and a stack trace.

The MCP surface decides nothing. The tool names are the ones a person types, the schemas
are the modules' own, and an answer is passed through unshaped — an adapter that
summarised somebody else's answer would be deciding what matters in it. A tool that fails
comes back as a tool error rather than a protocol error, because the request was
well-formed and the mesh answered it.

Written against the protocol directly: it is three methods and one framing, and a
dependency here would be a dependency on every workstation.

Tests drive both surfaces against a real bus, including that a host's notification is
answered with nothing and an unknown method is refused. They run one file at a time,
because each stands up a module serving the same tool subjects and run together their
requests get split between them — which showed up as one test reading another's answer.
2026-09-27 17:03:13 +02:00
jschoubben fbeb373d1a Both clients map local event names to their own wire
A module names its events locally and each transport works out where they land.
That is what design 29 says and what neither client did: both passed the name
straight through, which happened to be right on the old bus because modules were
writing routing keys, and wrong on the new one (novox/hq 04-ISSUES/127).

The old bus's client now turns a local name into `module.<emitter>.<event>` on the
way out and back on the way in. Without that, converting the modules to local
names would have broken the mesh that is actually running.

**A handler and a manifest now say the same thing.** The key a module sees was the
event name alone, so a manifest declaring `consumes: builder.built` produced a
pattern that could never match what it was compared against — and a module
consuming one event from two emitters could only tell them apart by reading a
header. The subject already carries the emitter, so naming it in the key makes a
mismatch between manifest and code a typo instead of a category error.

Both matchers accept `**` for the rest of a name, which is how a manifest spells
it; the old bus's `#` still works, because both buses ship until the rollout.
2026-09-27 14:42:40 +02:00
jschoubben 2197c36fef Describe the client on its own terms
Same cleanup: the comments explained each decision by contrast with what
came before instead of stating it. The certificate constraint stays — it is
a fact about the mesh's certificates, not a comparison.
2026-09-26 23:51:00 +02:00
jschoubben 19560ca6a7 Hold the runtime's NATS client to the shared fixtures
Read back from the stream rather than from the client that wrote it, so the
check is what reached the wire. The runner lives with the implementation;
the fixture stays in one place.
2026-09-26 23:40:59 +02:00