Introduction
Pulse Mesh™ Daemon
A runner on the standard: the daemon that routes envelopes between processes through a local port, its Rust client, the dev ring, and the pms tools.
Pulse Mesh Daemon · working draft · reference implementation
Pulse Mesh™ is a transport substrate built on two byte layouts. The Pulse is a 128-byte control signal that occupies two cache lines and moves through a shared-memory ring without a lock. The PulseEnvelope carries anything larger, at any size, and is addressed by the 16-byte BLAKE3 hash of its canonical bytes, so the same content produces the same identifier in every language that implements the codec. The two layouts, their codec, the ring, the store and the wire are the standard, a repository of its own; this one builds on it.
This repository is the daemon: mesh-hub, which attaches processes over a Unix socket or a WebSocket, routes envelopes between them by channel, and keeps nothing for a subscriber that is not there; the port protocol those processes speak, in JSON, with its golden fixtures; the embedded hub runtime the daemon wraps; the Rust client (Mesh: attach, send, on, tap, run); the dev ring, the general-purpose ring composed on the standard; the pms command line, the pms-mcp server for agents, the telemetry exporter and the basket query tool; the Python and Node thin clients; and the two-process and multi-language examples. It depends on the standard as a sibling checkout (../pulse-mesh) while developing and as a pinned tag at a release.
This book documents the daemon in this repository. It is organised in four parts, each answering one kind of question, and every page states at its top which kind it is and whom it is for.
The four parts
| Part | The question it answers | How to read it |
|---|---|---|
| Examples | How do I see it working? | Hand the prompt to your agent, or follow the steps; each ends with something running and checks you can run. |
| Workflows | How do I do my domain’s job with it? | An example’s shape for one domain, saying what is implemented and what is deferred. |
| How-to guides | How do I do this particular task? | Find the task, follow the steps. |
| Reference | What exactly is true? | Look things up. The port protocol, the daemon’s flags and the generated tables live here. |
| Concepts | Why is it built this way? | Read for understanding. One invariant and one figure per page. |
A last part, Contributing, is for people changing the tree: the workspace, the gates, the decision record, and the documentation’s own rules.
Where to start
- Seeing the daemon work: Two programs talk through the daemon, then An agent swarm. Encoding and verifying one envelope first, and sending Pulses over UDP, is Across a network, in the standard’s book.
- Talking to the mesh from another language: the Python and Node steps of Two programs talk through the daemon with the thin clients, and the Rust client; the codec bindings are in the standard’s book.
- Understanding how the pieces fit: Fabric: mesh/ and the dev ring in the Reference part, then Daemon design and Substrate versus contract.
- Contributing: Developer Onboarding, then the documentation style if you are writing.
How this book relates to the rest of the repository
Two records are authoritative, and this book defers to both. The rustdoc (cargo doc --workspace --no-deps --open) is the API contract: every crate opens with an overview, every public item is documented, and a workspace lint keeps it that way. The decision log under decisions/ records why the non-obvious calls were made, in order and without rewriting. The book is the guided path through that material. Where the book and the code disagree, one of them is wrong, and either way it is a defect to report.
The specification is the standard’s, and this book cites it by chapter; only chapter 7, the port protocol, is here, because the daemon is what speaks it.
What this book claims and what it does not
The book describes the tree at the version shown in the sidebar. It records no measured figures. Where it describes a measurement it gives the command that produces one. Where a behaviour is specified but not yet implemented, the page says so and names the test that will prove it. Refutations are welcome: the fastest way to improve a page is to show where it is wrong.
Onboard your agent
Start here · what a coding agent needs to set up Pulse Mesh, run it, and put other agents on the mesh
Pulse Mesh is built to be set up and driven by a coding agent. This page is what you hand it: one prompt to get running, what it needs to know before it changes anything, the MCP server that gives it a live mesh, and how agents talk to each other through one.
1. Paste this
Open an empty folder in your agent and paste:
Set up Pulse Mesh for me. In one empty folder, clone these three repositories side by side, from the GitHub account that publishes Pulse Mesh (the account this repository belongs to; if you are already inside one of them, its git remote names it):
pulse-mesh
pulse-mesh-daemon
pulse-mesh-profiles
Install Rust with rustup if it is missing; each repository pins its version in rust-toolchain.toml.
Then in each repository read AGENTS.md and run its "Prove the tree works" steps up to scripts/gate.sh --quick.
Finally show me it working:
1. in pulse-mesh, encode, decode and verify one envelope with the pulse command line;
2. in pulse-mesh-daemon, run pms demo;
3. in pulse-mesh-profiles, run the robotics example.
Explain in plain words what each step showed. Stop and tell me if anything fails.
2. What your agent needs to know
- Three repositories, side by side.
pulse-meshis the standard;pulse-mesh-daemonandpulse-mesh-profilesbuild on it and find it at../pulse-mesh. AGENTS.mdin each one is the agent’s brief: setup, the order of the first commands, a map of the tree, and the rules every change keeps.- What is true, in order: the specification, then the test vectors and the port protocol’s fixtures, then the rustdoc, then the decision records, then the rest of this book. When two disagree, one is wrong, and the agent should say so rather than pick one.
- Done means the gate passes.
scripts/gate.shin each repository is the check a change passes before it merges. - What it must never do: edit a test vector or a fixture to make a test pass, make a deferred feature look implemented, or rewrite an accepted decision record.
3. Give your agent the mesh: MCP
pms-mcp, in the daemon’s repository, is a Model Context Protocol server. With a daemon running, it lets an agent attach to the mesh, send envelopes, check health, query and replay what the basket captured, read a ring’s slots, and round-trip the codec on input it supplies.
cargo install --path tools/pms-mcp --locked # from pulse-mesh-daemon
claude mcp add pms-mcp -- pms-mcp # Claude Code; any MCP client that can launch a command works
Use the MCP server from an agent registers it with other clients and tries it by hand, and the MCP tools lists every tool’s input and output.
4. Put agents on the mesh
Agents coordinate the way any programs on the mesh do: each attaches to the daemon by name, subscribes to the channels it cares about, and sends envelopes that reach every subscriber attached at that moment. One agent can plan and others can work:
pms start --foreground # one daemon for the machine
pms tap --channel 'jobs/*' # a worker listens for jobs
pms push jobs/new 'summarise report.pdf' # a planner hands one out
An agent with MCP does the same through mesh_attach and mesh_send. One without it speaks the port protocol directly over the daemon’s WebSocket, through the Python or Node thin client or plain JSON frames. Every envelope is named by a hash of its content, so a receiving agent can check it got exactly what was sent. Two programs talk through the daemon walks through it by hand.
The daemon has no authentication and no encryption, so keep it on loopback, which is its default, when agents share a machine.
An agent swarm builds this in ten minutes: a planner hands out jobs, two workers answer, and every result is checked against its job by hash.
Next
- Developer onboarding: the same setup by hand, in five minutes.
- Use the MCP server from an agent: every client, and the server’s limits.
Use the MCP server from an agent
How-to guide · for anyone giving an agent a running mesh to inspect, publish to, or prove the codec against
pms-mcp is a Model Context Protocol server over stdio: one JSON-RPC request per line in, one response per line out. Any MCP client that can launch a command can use it.
1. Install and check
just install # installs pms, mesh-hub and pms-mcp; or: cargo install --path tools/pms-mcp --locked
pms-mcp --help
The tools introspect a running daemon (mesh_attach, mesh_send, mesh_health, mesh_query, mesh_replay), prove the codec on supplied input (codec_roundtrip, mesh_envelope_describe), read shared memory (mesh_list_rings, mesh_ring_peek) and list the optimization modules (modules_list, modules_describe). The generated tool reference has each tool’s input schema. The two probe tools are registered only when PMS_MCP_ALLOW_PROBE=1 is set.
2. Register it with the client
With Claude Code:
claude mcp add pms-mcp -- pms-mcp
With any client that reads a JSON configuration, the shape is the same everywhere: a command and, when the basket is somewhere other than the default, its path in the environment.
{ "mcpServers": { "pms-mcp": { "command": "pms-mcp", "env": { "PMS_BASKET_DB": "/path/to/basket.db" } } } }
3. Try it without a client
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | pms-mcp | head -c 400
A daemon must be running for the mesh_* tools; the codec and module tools work without one.
What the server will not do
It has no write path into the ring other than mesh_send, which is the same publish any client can make; it does not start or stop the daemon; and it redacts nothing it did not read from the mesh, so what an agent can see through it is what any attached client can see.
Developer Onboarding
Start here · five minutes from a fresh checkout to a daemon you have watched work, by hand
Working with a coding agent? Start with Onboard your agent: one prompt sets up all three repositories. This page is the same setup by hand.
By hand
- Set up. Check out the standard at
../pulse-mesh; every manifest here reaches it there.rust-toolchain.tomlpins the Rust version, andrustupinstalls it on the firstcargocommand. - See it work.
cargo install --path tools/pms --locked cargo install --path mesh/mesh-hub --locked pms doctor pms demopms doctorchecks the toolchain and the environment.pms demostarts a daemon, passes five messages from a producer to a consumer through it, prints each one, and stops the daemon. - Run a mesh yourself. Two programs talk through the daemon starts a daemon and moves envelopes between terminals, programs and languages.
- Prove the tree.
scripts/gate.sh --quickruns the per-commit checks;scripts/gate.shruns the gate a change passes before it merges. A check whose tool is missing fails and prints the command that installs it.
From there, An agent swarm puts a planner and two workers on the mesh.
Before your first change
CONTRIBUTING.mdsays what a contribution means during the beta, holds the table of every check the gate runs, and gives the four-question test for where new code goes, which Workspace Layout explains.AGENTS.mdlists the rules every change keeps, for a person as much as for an agent: a port-protocol change moves the specification, the fixtures and both thin clients together; the daemon stays on loopback;unsafeonly with a// SAFETY:comment; decision records added and never rewritten.- The specification is normative, the rustdoc is the API contract, and the Decision log records why the non-obvious calls were made. This book explains and guides; where it and the code disagree, one of them is wrong.
Where to read first
mesh/mesh-hub-runtime/src/lib.rs: the in-process event bus (PulseHub) themesh-hubdaemon wraps.mesh/mesh-hub/src/: the daemon, its transports, its per-connection loop, and the bridge to the hub.mesh/mesh-port-protocol/src/lib.rsandfixtures/: the messages every client speaks, and the frames the thin clients are held to.mesh/mesh-client/src/client.rs: the Rust client (Mesh), whichdev-hubre-exports.tools/pms/src/main.rsandsrc/commands/: thepmscommand line, the most concrete tour of what the daemon does.
Three examples
Part opener · for anyone who wants to see Pulse Mesh work before reading about it
Each example ends with something running on your machine, and each is written to be handed to a coding agent as one prompt or followed by hand. Every step names the command and shows what it prints, and every example closes with checks you can run, what happened, and what the code does not do.
- Two programs talk through the daemon: start a daemon, publish from the command line, from two Rust programs, and from Python and Node, and watch every message named by its content.
- An agent swarm: a planner hands out jobs, workers answer: a planner and two workers on one mesh, every result checked against its job by hash.
- Across a network: encode, verify, send over UDP: one envelope in your hands, then Pulses over UDP between two terminals or two machines, with nothing but the standard.
The first two need the daemon’s repository; the third needs only the standard’s. Where an example lives in another book, its link goes there.
Two programs talk through the daemon
Example · for anyone running Pulse Mesh for the first time, in about fifteen minutes
You will have: a daemon on your machine, and messages moving through it from the command line, from two Rust programs, and from a Python publisher to a Node subscriber. You will have seen every message named by a hash of its content, the same name on every side, and what a subscriber misses while it is away.
You need: the daemon repository set up as Start here describes, with pms and mesh-hub installed and the Rust toolchain. For the other-language step, Python 3 with pip and Node 22 or later with npm; nothing in that step is compiled.
Hand this to your agent
Run the example "Two programs talk through the daemon" from book/src/examples/two-programs.md in the pulse-mesh-daemon repository:
1. Start a daemon in the background: pms start. It prints the process id and its log file. Confirm the log shows "websocket listening".
2. In the background, run pms tap --channel 'jobs/*' and keep its output. Then run pms push jobs/new 'first job' and show me the envelope_id it prints and the line the tap printed; the eight characters after "jobs/new |" in the tap line must be the start of that envelope_id.
3. Run pms push jobs/new 'first job' again and confirm the envelope_id is identical. Run pms push jobs/other 'first job' and confirm it differs.
4. Build and run the two Rust programs: cargo run --release -p multi-process --bin receiver in the background, wait for "attached and subscribed", then cargo run --release -p multi-process --bin sender. Show me that each "sender: sent 'job-N' as envelope X" has a matching "receiver: got 'job-N' (envelope X, 5 bytes)".
5. Install the thin clients, pip install -e clients/pms-py-thin and npm install --prefix peripheral/clients/pms-node-thin. Run node examples/multi-lang/node_subscriber.js in the background, then python3 examples/multi-lang/python_publisher.py, and show me that the identifiers match across the two languages.
6. Run pms ring inspect default_hub --count 2 and tell me what the cursor says about how many Pulses the daemon has written.
7. Stop everything you started, including the daemon (kill the process id from step 1).
Explain in plain words what each step showed. Stop and tell me if anything fails, and say which step.
Or do it by hand
1. Start the daemon
In a first terminal:
pms start --foreground
INFO uds listening path=/tmp/pms.sock
INFO websocket listening addr=127.0.0.1:9876
Those are the two endpoints clients find on their own: pms and the Rust client try the Unix socket, then the WebSocket port, unless PMS_DAEMON names an endpoint (unix:///path or ws://host:port). Leave this terminal alone. Without --foreground, pms start detaches the daemon, prints its process id and log file (~/.local/state/pms/mesh-hub.log), and returns; stop a detached daemon with kill and that id.
2. Watch a channel
In a second terminal:
pms tap --channel 'jobs/*'
tapped; Ctrl-C to stop
tap attaches under a generated name and subscribes to every channel matching jobs/*. It prints one line per event for as long as it runs. Leave it running.
3. Publish from the command line
In a third terminal:
pms push jobs/new 'first job'
envelope_id=cb05c6b8f4b696551a0b7245b6f104a2
and the tap prints:
jobs/new | 9B | cb05c6b8 | @6b19f89f
Left to right: the channel, the payload size, the first eight characters of the envelope identifier, and the origin, the short form of the publisher’s node id. Now publish three more:
pms push jobs/new 'first job'
pms push jobs/other 'first job'
pms push jobs/new 'first job' --type-name job.created
envelope_id=cb05c6b8f4b696551a0b7245b6f104a2
envelope_id=329b893bbb06850cd40679fd0a2d98d5
envelope_id=759d3a7c705c4d63cba9a36194fc980c
The first identifier is the same as before, because the content is the same: the identifier is computed from the channel, the type name and the payload, and from nothing else. Change the channel and it changes; add a type name and it changes.
4. Two Rust programs
The two programs are in the checkout under examples/multi-process/. The receiver:
//! Template: a receiver process subscribing to channel `jobs`.
//!
//! Pairs with `sender.rs` — see that file's docs for the run order.
#![allow(clippy::print_stdout, clippy::print_stderr)] // an example prints what it does
use mesh_client::Mesh;
fn main() {
if let Err(e) = receive() {
eprintln!("receiver: {e}");
std::process::exit(1);
}
}
fn receive() -> Result<(), Box<dyn std::error::Error>> {
let mesh = Mesh::attach("multi-process-receiver")?;
mesh.on("jobs", |ev| {
let payload = String::from_utf8_lossy(&ev.payload);
println!(
"receiver: got '{}' (envelope {}, {} bytes)",
payload,
&ev.envelope_id[..ev.envelope_id.len().min(8)],
ev.payload.len()
);
})?;
println!("receiver: attached and subscribed to 'jobs' (Ctrl-C to stop)");
// Returns only when the connection ends; a lost daemon is an error here.
mesh.run()?;
Ok(())
}
Mesh::attach connects to the daemon and registers the process under the name you give it. mesh.on subscribes to one channel and hands every event on it to the closure for as long as mesh.run() runs. The sender:
//! Template: a sender process publishing to channel `jobs` once per second.
//!
//! Run with the daemon already running:
//!
//! # Terminal 1
//! cargo run -p mesh-hub --release
//!
//! # Terminal 2
//! cargo run -p multi-process --bin receiver
//!
//! # Terminal 3
//! cargo run -p multi-process --bin sender
#![allow(clippy::print_stdout, clippy::print_stderr)] // an example prints what it does
use mesh_client::Mesh;
use std::thread;
use std::time::Duration;
fn main() {
if let Err(e) = send_jobs() {
eprintln!("sender: {e}");
std::process::exit(1);
}
}
fn send_jobs() -> Result<(), Box<dyn std::error::Error>> {
let mesh = Mesh::attach("multi-process-sender")?;
println!("sender: attached");
for i in 0..10 {
let payload = format!("job-{i}");
let env_id = mesh.send("jobs", payload.as_bytes())?;
println!(
"sender: sent '{}' as envelope {}",
payload,
&env_id[..env_id.len().min(8)]
);
thread::sleep(Duration::from_millis(1000));
}
println!("sender: done");
Ok(())
}
mesh.send publishes the bytes on the channel and returns the identifier the daemon computed. Start the receiver in the third terminal:
cargo run --release -p multi-process --bin receiver
receiver: attached and subscribed to 'jobs' (Ctrl-C to stop)
and the sender in a fourth:
cargo run --release -p multi-process --bin sender
sender: attached
sender: sent 'job-0' as envelope 796abc6e
sender: sent 'job-1' as envelope 5fba33ce
sender: sent 'job-2' as envelope 4166531f
receiver: got 'job-0' (envelope 796abc6e, 5 bytes)
receiver: got 'job-1' (envelope 5fba33ce, 5 bytes)
receiver: got 'job-2' (envelope 4166531f, 5 bytes)
The identifiers match line for line because both sides name the same envelope, and job-0 has the same identifier on every run. After ten messages the sender prints sender: done and exits; the receiver keeps running.
5. The same exchange from Python and Node
Install the two thin clients, each a single file with six calls (attach, send, on, tap, run, detach) and no codec: the daemon computes every identifier.
pip install -e clients/pms-py-thin
npm install --prefix clients/pms-node-thin
Start the Node subscriber:
node examples/multi-lang/node_subscriber.js
node: attached; listening on multi-lang/demo (Ctrl-C to stop)
and, in another terminal, the Python publisher:
python3 examples/multi-lang/python_publisher.py
py: attached
py: sent b'hello-from-python-0' as fe531358
py: sent b'hello-from-python-1' as a2bcbef7
node: got 'hello-from-python-0' (envelope fe531358)
node: got 'hello-from-python-1' (envelope a2bcbef7)
Run pms push multi-lang/demo hello-from-python-0 and it prints the same identifier, fe531358…: three languages, one name for one content.
6. Look at the ring underneath
pms ring inspect default_hub --count 2
ring /pms-default_hub
capacity 64 slots
write_count …
cursor …
recent 2 pulse(s):
slot= … inst=DATA mono_ns= … has_envelope=true
slot= … inst=DATA mono_ns= … has_envelope=true
The ring is a shared-memory object named after the hub. Every envelope published through the daemon writes one 128-byte Pulse into it, and the cursor counts them. has_envelope is true on every slot: the Pulse carries the envelope’s identifier, and a second process attached to the hub fetches the envelope from the store beside the ring by that identifier. That is how envelopes cross processes.
7. Stop the daemon
Ctrl-C in the first terminal. The daemon removes its socket and unlinks its shared memory as it exits.
Check it worked
| Check | Command | Pass |
|---|---|---|
| The command line, Rust, Python and Node all name content the same way | Compare pms push’s envelope_id, the sender’s and receiver’s lines, and the Python and Node lines | The same eight characters on every side of each message |
| Identity is content | pms push jobs/new 'first job' twice, then on jobs/other | Identical identifiers, then a different one |
| The daemon counts what it carried | pms ring inspect default_hub --count 2 | cursor equals the number of envelopes published since the daemon started |
| A subscriber away misses what was sent | Run the sender again; stop the receiver at job-2, wait two seconds, restart it | The receiver prints from job-5 or job-6 on, and nothing for the gap |
If pms push reports it cannot reach a daemon, none is running or PMS_DAEMON points elsewhere; the daemon’s log names its endpoints. If the Node subscriber or Python publisher fails to connect, PMS_DAEMON must be a ws:// address or unset: the thin clients speak WebSocket only.
What happened
- Identity is content. The daemon names every envelope by the BLAKE3 hash of its canonical bytes: channel, type name and payload. Two envelopes with identical content share one name, in every language, so a receiver can check what it got without trusting who sent it.
- A subscription is a live stream. It delivers from the moment the daemon acknowledges the subscribe request, and the daemon keeps nothing for a subscriber that is not connected; the messages sent in a gap are gone for that subscriber. A subscriber that stays connected but stops reading is a different case: the daemon queues up to a fixed number of events for it (
--subscriber-queue, 1,024 by default), drops newer ones beyond that, and reports how many when the subscription ends. - Every subscriber gets every matching message. The daemon broadcasts to the subscriptions whose filter matches; it does not pick one.
- The ring is the signal, the store is the content. One 128-byte Pulse per envelope goes into the shared-memory ring; the envelope itself sits in the store beside it, fetched by identifier.
What the daemon does not do: it has no authentication and no encryption, so it listens on loopback by default and should stay there unless a deployment wraps it; it keeps no history for late subscribers (the basket, in Capture envelopes and query them, is the record); and mesh_health in the MCP server reports which names are attached, not whether they are alive.
Make it yours
- See a whole event: stop the tap and start it with
--json; each event is one JSON object with the channel, the identifier, the origin, the capture time and the payload size. - Speak the protocol yourself: in a browser’s console, open
new WebSocket('ws://127.0.0.1:9876', ['pms-universal-port-v0'])and send three JSON frames,attach,subscribeandsend, with a base64 payload; four come back, three acks and the event for your own envelope. The port protocol is the whole contract. - Type your messages:
--type-nameonpms push, or thetype_nameargument in any client, becomes part of the identifier and a filter a subscriber can use.
Next
- An agent swarm: a planner and two workers on this daemon, every result checked against its job.
- Capture envelopes and query them: keep a record of what went through, and ask it questions.
- Run the daemon as a service: flags, launchd and systemd, what a restart costs.
- Daemon design: why it is shaped this way.
Spoken walkthrough
A script for the video and audio version of this page, about two minutes.
One daemon, and four ways to talk to it.
First the command line. We start the daemon, open a tap on the jobs channels, and push a message. The tap shows it arrive, and both sides print the same eight characters: the start of the message’s identifier. Push the same message again and the identifier is the same. Push it on another channel and it changes. That identifier is a hash of the content, so identity is the content, not who sent it or when.
Next, two Rust programs, each about a screen long. The receiver attaches and subscribes. The sender attaches and publishes ten jobs. Every job the sender names, the receiver names the same way.
Now Python and Node. Two thin clients, no codec in either; the daemon does the naming. A Python publisher, a Node subscriber, and the identifiers still match.
Finally we look underneath. Every message put one 128-byte Pulse into a shared-memory ring, and the ring’s cursor counts them. The Pulse carries the identifier; the content sits in a store beside the ring.
One thing to try: stop the receiver while the sender is running, then start it again. It picks up from the next job and never sees the ones sent while it was away. A subscription is a live stream. The record of what went by is the basket, which the next example uses.
An agent swarm: a planner hands out jobs, workers answer
Example · for anyone putting more than one agent or program on a mesh, in about ten minutes
You will have: a planner (your coding agent) and two worker programs attached to one daemon on your machine. The planner publishes jobs. Every worker receives every job and publishes a result. The planner reads the results and matches each one to the job it answers by the job’s envelope identifier, which is a hash of the job’s content.
You need: the daemon repository set up as Start here describes, with pms and mesh-hub installed; Python 3.9 or later with pip; and, for the agent path, a coding agent that can use MCP tools. Claude Code is shown. The by-hand path needs no agent.
Hand this to your agent
Run the agent-swarm example from book/src/examples/agent-swarm.md in the pulse-mesh-daemon repository:
1. Start a daemon with a basket: pms start --basket-path ~/.local/share/pms/basket.db
2. Install the Python thin client and the query tool: pip install -e clients/pms-py-thin -e tools/pms-query
3. Save the worker program from the page as worker.py. Start two workers in the background: python3 worker.py worker-1 and python3 worker.py worker-2. Wait for each to print "attached, waiting for jobs".
4. If pms-mcp is not among your MCP tools: cargo install --path tools/pms-mcp --locked, then register the command pms-mcp as an MCP server and reconnect.
5. With the mesh_send tool, as_name "planner", publish three jobs on channel jobs/new, each a short sentence, with type_name job.new. Keep the envelope_id each call returns.
6. With the mesh_query tool, list rows on channel results. Expect six rows, two per job, with origins worker-1@… and worker-2@….
7. For one result row, run pms-query get <id> in the shell, decode payload_b64, and show me the text. Its eight-character job prefix must match one of the envelope_ids from step 5.
Stop and tell me if anything fails, and say which step.
Steps 5 to 7 are the planner’s work. If your agent has no MCP, it can do the same with pms push and pms-query, as the by-hand path below does.
Or do it by hand
1. Start a daemon that keeps a record
In a first terminal:
pms start --foreground --basket-path ~/.local/share/pms/basket.db
The daemon prints where it listens (timestamps omitted):
INFO uds listening path=/tmp/pms.sock
INFO websocket listening addr=127.0.0.1:9876
--basket-path makes the daemon capture every published envelope into a SQLite file, the basket. The planner reads results from it in step 5. Leave this terminal alone.
2. Write the worker
Install the Python thin client and the query tool, from the repository root:
pip install -e clients/pms-py-thin -e tools/pms-query
Save this as worker.py:
# worker.py: attach under the name given, answer every job on jobs/new.
import sys
import pms
name = sys.argv[1] if len(sys.argv) > 1 else "worker"
mesh = pms.attach(name)
def answer(event):
text = event.payload.decode("utf-8", errors="replace")
result = f"{name}: {len(text.split())} words in job {event.envelope_id[:8]}"
mesh.send("results", result.encode("utf-8"), type_name="job.done")
print(result, flush=True)
mesh.on("jobs/new", answer)
print(f"{name}: attached, waiting for jobs", flush=True)
mesh.run()
The worker attaches to the daemon at ws://127.0.0.1:9876 (or wherever PMS_DAEMON points), subscribes to jobs/new, and for each job publishes one result on results that names the job by the first eight characters of its envelope identifier.
3. Start two workers
In a second terminal:
python3 worker.py worker-1
worker-1: attached, waiting for jobs
In a third terminal, the same with worker-2.
4. Publish a job
In a fourth terminal:
pms push jobs/new 'count the words in this job' --type-name job.new
The publisher prints the identifier the daemon gave the job:
envelope_id=cb05c6b8f4b696551a0b7245b6f104a2
Both workers print a line, each with the same eight-character prefix:
worker-1: 6 words in job cb05c6b8
worker-2: 6 words in job cb05c6b8
Publish two more jobs with different sentences.
5. Read the results
pms-query rows --channel results --since 5min
{
"rows": [
{
"id": "…",
"captured_at_unix_nanos": …,
"channel": "results",
"envelope_type": 0,
"type_name": "job.done",
"origin": "worker-2@…",
"payload_hash": "…"
},
…
],
"next_before": null
}
Six rows: two per job. Rows carry the result’s identifier, origin and hash, not its text; fetch one result’s text by its id:
pms-query get <id> | python3 -c 'import json,sys,base64; print(base64.b64decode(json.load(sys.stdin)["payload_b64"]).decode())'
worker-2: 6 words in job cb05c6b8
Check it worked
| Check | Command | Pass |
|---|---|---|
| Every job was answered by every worker | pms-query rows --channel results --since 5min | Two rows per job you published, one per worker name in origin |
| Each result names its job | Decode one result’s payload as in step 5 | The eight characters after job match the start of an envelope_id that pms push (or mesh_send) printed |
| The daemon kept the jobs too | pms-query rows --channel jobs/new --since 5min | One row per job, type_name job.new |
If rows is empty, the daemon was started without --basket-path, or pms-query is reading a different file: it uses PMS_BASKET_DB, else ~/.local/share/pms/basket.db. If a worker prints nothing when you publish, it is attached to a different daemon: both read PMS_DAEMON, and the daemon’s log names its WebSocket address.
What happened
- Broadcast is the default. The daemon delivers each envelope to every subscriber attached at that moment. That is why two workers give two answers. To split work instead of duplicating it, give each worker its own channel (
jobs/worker-1), or let workers claim jobs by publishing a claim before the answer. The daemon does not assign a job to exactly one worker. - Identifiers are hashes. The envelope identifier
pms pushprinted is the BLAKE3 hash of the job’s canonical bytes. The same job content gives the same identifier everywhere, so a result that names it can be checked against the job without trusting the worker. - The planner reads the record, not the stream. The MCP server publishes and queries; it has no tool that waits for events. Live listening is for programs, through the thin clients or the Rust client. The basket is the planner’s view of what happened, and
mesh_queryrows carry identifiers, origins and hashes; payload text comes frompms-query get. - Nothing is kept for a worker that was not attached. A job published before a worker starts is not delivered to it later. The basket still records it.
What the daemon does not do yet: it has no authentication and no encryption, so keep it on loopback, which is its default; mesh_health reports which names are attached, not whether they are alive.
Make it yours
- More workers: start
worker-3; every job now gets three answers. - Specialists: change the worker to subscribe to
jobs/<name>and publish tojobs/worker-1from the planner. Each worker then sees only its own jobs. - Another language: the Node thin client has the same
attach,on,sendandrun; the Rust client inmesh/mesh-clienthas the same shape. Two programs talk through the daemon shows a Node subscriber and a Python publisher side by side. - Replay a day’s jobs:
mesh_replayrepublishes a captured channel, so a new worker can be tested against yesterday’s jobs. It reaches live subscribers too, so run it against a daemon of its own.
Next
- Two programs talk through the daemon: the same exchange from the command line, from Rust, and from Python and Node.
- Capture envelopes and query them: the basket’s filters, aggregations and retention.
- The MCP tools: every tool’s input and output.
- Trust boundaries: what the daemon protects, and what it leaves to the deployment around it.
Spoken walkthrough
A script for the video and audio version of this page, about ninety seconds.
Here’s a planner and two workers on one machine, talking through one daemon.
First, the daemon. We start it with a basket, which means it keeps a record of every message in a small database file. That record is how the planner will read results.
Next, the workers. Each one is fifteen lines of Python. It attaches to the daemon by name, subscribes to the jobs channel, and for every job it receives, it counts the words and publishes a result. The result names the job by the first eight characters of the job’s identifier.
Now the planner publishes a job. Watch both workers: each prints an answer, and each answer carries the same eight characters. That identifier is a hash of the job’s content, so the planner can check that a result belongs to a job without trusting the worker that sent it.
Finally the planner reads the results from the basket: two per job, one from each worker.
One thing to notice. Both workers answered, because the daemon broadcasts: every subscriber gets every message. If you want each job done once, give each worker its own channel. That’s the whole example. The code on the page is everything you saw.
One domain’s job, end to end
Part opener · for anyone putting Pulse Mesh to work in robotics, enterprise messaging, high-frequency trading or distributed compute
A workflow has an example’s shape and one domain’s job: what you will have, one prompt for your agent, the same steps by hand with what each prints, checks, and what the code does and does not do yet. Where a workflow uses a ring from the profiles repository, it says which of the ring’s parts are implemented and which are deferred.
- The ring inside your process: the shared-memory ring and the envelope store without the daemon, which is what the HFT ring builds on.
- HFT: one feed, many readers, no copy per reader: one writer, four readers and a ring in process memory, with the wait strategy chosen for the host and the path to several writers and NUMA placement.
- Robotics: a 4 MiB scan through shared memory: a scan in a shared-memory pool, a 16-byte reference in the Pulse, read and checked in another process, and the pool’s limits seen.
- Enterprise: Pulses between services over TLS 1.3: one frame across a verified TLS 1.3 handshake, and the stage’s own statement of which modes are deferred.
- Distributed compute: jobs across machines through linked daemons: two daemons linked over sealed UDP, a job crossing from one to a subscriber of the other, and the path to the swarm across hosts, LAN discovery and a relay.
Distributed compute: jobs across machines through linked daemons
Workflow · for anyone spreading one job over several machines, or putting an agent swarm across hosts; about twenty minutes, with the daemon repository
You will have: two daemons linked to each other, on one machine here and on two by changing two addresses; a job published to one daemon arriving at a subscriber of the other, carrying its origin qualified by the daemon it came from; the link’s counters showing the forward; and the path from here to the planner and workers of An agent swarm spread over hosts, to discovery on a LAN, and to a relay.
You need: the daemon repository set up as Start here describes, with pms and mesh-hub installed. For two machines, UDP between them on the link ports you choose.
Hand this to your agent
Run the workflow "Distributed compute" from book/src/workflows/distributed-compute.md in the pulse-mesh-daemon repository, on this machine:
1. A daemon prints its node key's public half only when its links are configured, so bootstrap each key with a placeholder peer. Start daemon A in the background: mesh-hub --hub-name lnkA --no-uds --ws-bind 127.0.0.1:9876 --node-key ./a.key --link-bind 127.0.0.1:7400 --link-peer 127.0.0.1:7401=<64 zeros>; wait for its "links to other daemons configured" log line, record the public_key it prints, and stop it. Do the same for daemon B with --hub-name lnkB, --ws-bind 127.0.0.1:9877, --node-key ./b.key, --link-bind 127.0.0.1:7401 and a placeholder peer at 127.0.0.1:7400.
2. Start both again, each naming the other's public key in --link-peer, and wait for a "link up" log line from each.
3. Against B (PMS_DAEMON=ws://127.0.0.1:9877), run pms tap --channel 'jobs/*' in the background, wait for "tapped", then a second more, so B's interest summary has reached A.
4. Against A (PMS_DAEMON=ws://127.0.0.1:9876), run pms push jobs/new 'hello from daemon A'. Show me the envelope_id and the line the tap on B printed; its origin must end in @ followed by daemon A's 16-digit id, the daemon_id from A's log.
5. Stop everything and show me each daemon's "links at exit" line: A forwards_sent=1, B forwards_received=1.
Explain in plain words how the job crossed from A to B and what the qualified origin means. Stop and tell me if anything fails, and say which step.
Or do it by hand
1. Give each daemon a node key
Links are authenticated by each daemon’s X25519 node key, which the daemon generates into the file --node-key names on its first start, and whose public half it logs. It logs it only when its links are configured, so the first start names a placeholder peer. In a first terminal:
mesh-hub --hub-name lnkA --no-uds --ws-bind 127.0.0.1:9876 --node-key ./a.key \
--link-bind 127.0.0.1:7400 \
--link-peer 127.0.0.1:7401=0000000000000000000000000000000000000000000000000000000000000000
INFO links to other daemons configured daemon_id=8f0882adae3fafa5 public_key=36ae5e96…f47a link_bind=127.0.0.1:7400 peers=… announce=None relay=false trusted_relays=…
INFO websocket bound bound=127.0.0.1:9876 scheme="ws"
Keep A’s public_key (64 hex digits) and daemon_id, then stop it with Ctrl-C. In a second terminal do the same for B, with --hub-name lnkB, --ws-bind 127.0.0.1:9877, --node-key ./b.key, --link-bind 127.0.0.1:7401 and the placeholder peer at 127.0.0.1:7400; keep B’s public key and stop it.
2. Link them
Start A again with B’s key, and B with A’s:
mesh-hub --hub-name lnkA --no-uds --ws-bind 127.0.0.1:9876 --node-key ./a.key \
--link-bind 127.0.0.1:7400 --link-peer 127.0.0.1:7401=<B's public key>
mesh-hub --hub-name lnkB --no-uds --ws-bind 127.0.0.1:9877 --node-key ./b.key \
--link-bind 127.0.0.1:7401 --link-peer 127.0.0.1:7400=<A's public key>
Each logs, within a second or two:
INFO link up peer=83cec7bc66fe3ca8 addr=Some(127.0.0.1:7401) version=3 capabilities=0 registry_match=true relays=false trusted_as_relay=false
peer is the other daemon’s id, the fingerprint of its public key. Each daemon’s --link-bind is one UDP endpoint, whatever the number of its peers; the link between two daemons is one sealed session between their endpoints.
3. A worker on B, a job from A
In a third terminal, a subscriber on B:
PMS_DAEMON=ws://127.0.0.1:9877 pms tap --channel 'jobs/*'
tapped; Ctrl-C to stop
Give it a second: B’s summary of what its subscribers want reaches A a fraction of a second after the subscription. Then, in a fourth terminal, a job published to A:
PMS_DAEMON=ws://127.0.0.1:9876 pms push jobs/new 'hello from daemon A'
envelope_id=c4ca9156f3946f9cb120990d488325d4
and the tap on B prints:
jobs/new | 19B | c4ca9156 | pms-cli-send@8f0882adae3fafa5
The origin is qualified: the publisher’s name, @, and the 16-digit id of the daemon it attached to. A had forwarded the send to B because B had told A, in its interest summary, that a subscriber of its wanted jobs/*.
4. Stop, and read the counters
Ctrl-C the tap and both daemons. Each daemon’s last lines include:
INFO links at exit links_up=1 forwards_sent=1 forwards_received=0 duplicates_dropped=0 forwards_dropped=0
on A, and forwards_sent=0 forwards_received=1 on B.
Across two machines: give each daemon its machine’s address in --link-bind and the other machine’s in --link-peer, open the two link ports for UDP between them, and keep --ws-bind on loopback on each machine; clients attach to the daemon on their own machine. Nothing else changes.
Check it worked
| Check | Where | Pass |
|---|---|---|
| The daemons authenticated each other | Both logs | A link up line on each, naming the other’s id |
| A job on A reached a subscriber on B | The tap on B | The jobs/new line, with @ and A’s daemon_id at the end of the origin |
| Exactly one forward | Both links at exit lines | forwards_sent=1 on A, forwards_received=1 on B, duplicates_dropped=0 |
| The pushed envelope is the delivered one | envelope_id from pms push and the tap line | The same first eight characters |
If a daemon exits with status 2 saying links need --node-key, --link-bind and a peer, one of the three is missing: all three are required, which is why step 1 names a placeholder. If link up never appears, the keys are swapped or a port is blocked; a daemon admits a peer’s key only from the IP of the address its --link-peer names. If the tap prints nothing, the subscription was made after the push, or the push came before B’s interest summary reached A, a fraction of a second after the subscription; forwarding follows subscriptions, so subscribe first and give it a moment.
What happened
- Daemons link over sealed UDP. Each
--link-bindis one endpoint; a link between two daemons is a Noise IK session with ChaCha20-Poly1305, keyed by their X25519 node keys, and only the configured peers’ keys are admitted, each from its address’s IP. A link begins with a Hello carrying the capability Hello ofmesh-capability, inside the session. - Forwarding follows interest. Each daemon tells its peers a summary of what its subscribers want, paced so it is sent at most a few times a second. A client’s send is forwarded once to each linked peer whose summary matches, and delivered there to the subscribers whose filters match. A daemon also announces its attached clients to its peers by qualified id, so a send with a destination can be routed to the daemon that holds it.
- The origin travels. Across a link a client is
name@and its daemon’s 16-digit id, so a worker on B knows which daemon a job came from, and a result published on B reaches a subscriber on A the same way, by B’s forward. - One hop, or two through a relay. A send leaves its daemon with a hop limit of two. Daemons not linked to each other reach one another through a daemon started with
--link-relay, which forwards once more and announces the clients reached through it; a daemon accepts relayed traffic only from a peer it names with--link-trust-relay, because a relayed origin is only as trustworthy as the relay. There is no third hop. - Finding peers on a LAN. With
--link-announce GROUP:PORTa daemon multicasts its link endpoint every five seconds and hears the others’, so--link-peer-keynames a peer by key alone and its address is learned; the announcement is a hint, acted on only for a key already configured, and the handshake then authenticates it.
What this does not do yet: the link’s sealing has not had the independent cryptographic review that ADR-0018 requires before sealed mode is recommended for production, so run linked daemons between machines you control; nothing signs an envelope or its origin end to end, which is why relays must be trusted by name; and forwarding is to subscribers, not to a chosen worker, so splitting a job across machines is done the way the agent swarm does it on one, by channel or by claim.
Make it yours
- The swarm across hosts. Run the planner of An agent swarm against the daemon on one machine, start the workers against daemons on others, and start every daemon with a basket. Jobs on
jobs/newreach every worker’s daemon; results onresultscome back the same way, each origin naming the worker’s daemon;pms-queryon the planner’s basket sees them all. - Per-machine channels. Workers on machine B tap
jobs/b/*; the planner pushes tojobs/b/new; forwarding carries the job only to daemons whose subscribers want that channel. - A relay for a third machine. Start a daemon with
--link-relaylinked to A and C, and name it in both with--link-trust-relay; A’s jobs reach C’s workers through it, one hop further. - Without a daemon. For one sender and many receivers on a LAN,
pulse_wire_udp’sReceiver::bind_multicastandSender::set_multicastcarry Pulses to a multicast group with no daemon between; Across a network is the two-ended version.
Next
- An agent swarm: the planner and workers this spreads across hosts.
- Run the daemon as a service: the link flags beside the rest, and what a restart costs.
- Trust boundaries: what a link protects and what it does not.
- The mesh-hub daemon: every link flag with its default.
Spoken walkthrough
A script for the video and audio version of this page, about two minutes.
Two daemons, two machines, one job.
Each daemon gets a key. It makes the key itself, the first time it starts, and prints the public half in its log. We give each daemon the other’s public key and the other’s address, and they link: one encrypted UDP session between them, and a line in each log saying the link is up and naming the other side.
Now a worker subscribes to the jobs channels on daemon B, and a planner pushes a job to daemon A. Watch the worker’s terminal: the job arrives. Look at where it came from: the publisher’s name, an at sign, and daemon A’s id. The job crossed the link because B had told A that someone on its side wanted jobs.
When we stop the daemons, each prints its counters: A forwarded one, B received one, nothing duplicated.
To do this between real machines, change two addresses. To add a third machine that is not linked to the first, add a relay, and tell the others to trust it by name, because a relay’s word is the only word you have about where a forwarded job came from.
And one plain statement: the link’s encryption has not yet had the independent review the Foundation requires before it recommends it for production. Between machines you control, this is how a job crosses the room.
Get one task done
Get one task done
Steps in order, with the result you should see after each one.
Part opener · for people who already run Pulse Mesh
A guide does not teach. It names the task, states what you need, and gives the steps in order, with the result you should see after each one. Background belongs in the Concepts part and exact facts in the Reference part; a guide links to both instead of repeating them.
- Install pms outside the workspace: the three binaries on your path.
- Run the daemon as a service: flags, launchd, systemd, and what a restart costs.
- Capture envelopes and query them: the basket and the three ways to ask it.
- Use the MCP server from an agent: register
pms-mcpand try it by hand. - Inspect and clean shared memory: read a ring, remove what a crash left.
- Troubleshoot: the errors a user meets and their remedies.
- Add a stage to a ring: where a stage goes and what declares it.
Tuning the UDP transport, writing a language binding and running the smoke bench are in the standard’s book.
Install pms outside the workspace
How-to guide · for anyone who wants `pms` and the daemon on their path rather than behind `cargo run`
You need the Rust toolchain pinned in rust-toolchain.toml (rustup picks it up) and a clone of the repository. Linux or macOS; the rings use POSIX shared memory.
1. Install the three binaries
just install
Without just, the recipe is three commands:
cargo install --path tools/pms --locked
cargo install --path mesh/mesh-hub --locked
cargo install --path tools/pms-mcp --locked
pms, mesh-hub and pms-mcp land in ~/.cargo/bin, which rustup puts on your path. pms start looks for mesh-hub beside its own executable first and on your path second, and falls back to cargo run only inside the workspace, so the installed pair works from any directory.
2. Check
pms version
pms doctor
version prints the workspace and codec versions the binary was built from; doctor checks the toolchain and the runtime preconditions and names anything missing.
3. Run
pms demo
The demo starts a daemon if none is running, exchanges five envelopes between a producer and a consumer, and stops the daemon it started. From here the examples take over.
Upgrading
Repeat step 1 after pulling a new release; cargo install replaces the binaries. The book’s version in the sidebar is the version the pages describe; pms version is the version you have.
Run the daemon as a service
How-to guide · for anyone keeping `mesh-hub` running for other processes on a machine
mesh-hub runs in the foreground and stops on SIGINT or SIGTERM; a process supervisor keeps it up. Install the binary first (Install pms outside the workspace).
1. Choose the flags
| Flag | Default | Use |
|---|---|---|
--hub-name | default_hub | Names the shared-memory objects /pms-<hub> and /pms-<hub>-envelopes; at most 15 characters of letters, digits, _, . and -. One name per daemon on a host. |
--uds-path | /tmp/pms.sock | The Unix socket, created with owner-only permissions. |
--ws-bind | 127.0.0.1:9876 | The WebSocket address. Keep it on loopback unless something authenticates in front of it; the daemon does not. |
--no-uds, --no-ws | off | Disable one transport. |
--basket-path | none | Capture every published envelope to a SQLite file for structured queries. |
--ping-interval-secs, --idle-timeout-secs, --write-timeout-secs, --handshake-timeout-secs, --max-message-bytes, --subscriber-queue | 30, 90, 10, 5, 1048576, 1024 | The per-connection policy; the generated reference lists each. |
--max-subscriptions, --max-connections | 64, 256 | Subscriptions one connection may hold, and connections the daemon holds across both transports. Beyond either the request is refused with limit_exceeded; a refused connection is closed, a WebSocket upgrade past the cap is answered 503, and the exit log counts connections_refused. |
Logging goes to standard error and is controlled by MESH_HUB_LOG (for example info,mesh_hub=debug).
2. macOS: launchd
Save as ~/Library/LaunchAgents/org.sovfound.mesh-hub.plist, then launchctl load it:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>org.sovfound.mesh-hub</string>
<key>ProgramArguments</key><array>
<string>/Users/you/.cargo/bin/mesh-hub</string>
<string>--hub-name</string><string>default_hub</string>
</array>
<key>EnvironmentVariables</key><dict><key>MESH_HUB_LOG</key><string>info</string></dict>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardErrorPath</key><string>/Users/you/Library/Logs/mesh-hub.log</string>
</dict></plist>
3. Linux: systemd
Save as ~/.config/systemd/user/mesh-hub.service, then systemctl --user enable --now mesh-hub:
[Unit]
Description=Pulse Mesh daemon
[Service]
ExecStart=%h/.cargo/bin/mesh-hub --hub-name default_hub
Environment=MESH_HUB_LOG=info
Restart=on-failure
[Install]
WantedBy=default.target
4. Know what restarts cost
The shared-memory objects are unlinked when the daemon exits, so a restart begins with an empty ring and an empty store; the basket file, if any, persists. A daemon started while another is live on the same socket path refuses to start and says so; a socket file left by a daemon that was killed is reclaimed. Clients reconnect on their own terms: the Rust client’s wait() returns Disconnected, and a subscriber re-attaches and re-subscribes.
On Linux the ring and the store live in the tmpfs behind /dev/shm, and the daemon reserves their pages when it creates them: about 17 MB per hub, a 256-slot store of 64 KiB entries plus the ring. Docker’s default /dev/shm is 64 MB, room for three hubs; give a container more with docker run --shm-size=256m. Shared memory without room is refused when the daemon starts, with could not reserve … bytes of shared memory in the log, instead of a SIGBUS at the first page touched later; a store without room is logged and the daemon runs without persistence, so its send acknowledgements carry no persisted field.
5. Look at it
pms ring inspect default_hub
pms tap
The first reads the ring’s header and its most recent slots; the second attaches and prints every event until you stop it.
Capture envelopes and query them
How-to guide · for anyone who wants to ask a running mesh what went through it, without watching a live stream
The daemon can capture every published envelope into a SQLite file, the basket, and three tools query it: the pms-query command, the MCP mesh_query tool, and any SQLite client, because the schema is one table.
1. Start the daemon with a basket
mesh-hub --basket-path ~/.local/share/pms/basket.db
or pms start --basket-path <file>. A retention sweep runs every 60 seconds (--basket-retention-secs) and keeps the file to 100 MB or 24 hours of envelopes, whichever is smaller.
The daemon writes the basket on a thread of its own, so a send never waits for SQLite. Captures wait for that thread in a queue of 1,024 envelopes and 64 MiB of payload; when the disk falls that far behind, a capture is dropped, the envelope is still delivered, and the daemon logs the count at the first drop and at each doubling. Stopping the daemon writes every capture still queued.
2. Query from the command line
pms-query is a small Python package under tools/pms-query; install it with pip install -e tools/pms-query. It reads the file at PMS_BASKET_DB or ~/.local/share/pms/basket.db unless --db says otherwise.
pms-query rows --channel 'jobs/*' --since 5min --limit 20
pms-query rows --aggregate count-by-channel --since 1h
pms-query rows --aggregate time-bucket --bucket-secs 60 --since 1h
pms-query rows --origin 'worker@3f9a' --type-name event.fired
pms-query get cb05c6b8f4b696551a0b7245b6f104a2
rows takes filters that AND-combine, --channel, --type-id, --type-name, --origin, --since and --until (durations such as 5min, 1h, 1d), and --limit (50 by default). --aggregate is one of count, count-by-channel, distinct-channels, distinct-types and time-bucket (with --bucket-secs). Output is JSON: rows with the envelope id, capture time, channel, type, origin and payload hash, or the aggregate, plus next_before for paging. get fetches one envelope by identifier with its payload base64-encoded.
3. Query from an agent
The MCP server’s mesh_query tool takes the same filters and aggregations and answers with the same rows; mesh_replay republishes a captured range. See Use the MCP server from an agent.
4. Query with SQLite
One table, envelopes, with the columns id, captured_at_unix_nanos, channel, envelope_type, type_name, origin, payload_hash and payload_bytes. sqlite3 basket.db 'select channel, count(*) from envelopes group by channel' is a valid query. The mesh-basket crate owns the schema; the Python tool’s tests assert the column list.
What the basket is not
It is not the envelope store: the store is shared memory beside the ring, holds one copy per distinct envelope, and is unlinked when the daemon exits; the basket is a file, holds one row per publication, and persists. A Pulse’s envelope_ref resolves against the store, never the basket.
Inspect and clean shared memory
How-to guide · for anyone who wants to see what is in a ring, or to remove what a crashed daemon left behind
Read a ring
pms ring inspect default_hub
pms ring inspect default_hub --count 16 --json
A hub name resolves to the daemon’s ring, /pms-default_hub; a raw shared-memory name, one starting with /, is read as given. Either way the ring’s own header supplies its capacity and slot size, so nothing else is asked for:
pms ring inspect /pms-example-4242
The output is the ring’s name, its capacity, the total number of writes and the cursor, then the most recent slots with their instruction, their clock reading and whether they reference an envelope. inspect attaches as a second reader and moves nothing; a slot whose writer died mid-write reads as absent until the next publish into it.
The same from an agent
The MCP tools mesh_list_rings and mesh_ring_peek list the /pms-* objects (on Linux, where /dev/shm can be listed) and read a ring by hub name or raw name with the same two forms.
Clean up
A daemon unlinks its objects when it exits cleanly. After a crash or a kill, on Linux the objects stay under /dev/shm:
ls /dev/shm/pms-*
just clean # cargo clean, then removes /dev/shm/pms-* owned by you
On macOS shared-memory objects have no directory; they persist until a process unlinks them or the host reboots. A daemon started under the same hub name unlinks what it finds before creating its own ring and store, and its log names each object it replaced, so the way to clear a leftover on macOS is to start and stop a daemon with that hub name.
A stale socket
/tmp/pms.sock left by a killed daemon is reclaimed by the next daemon on that path, which probes it first; a socket that answers is another live daemon and the new one refuses to start.
Add a stage to a ring
How-to guide · for a contributor adding stateful processing to a ring
Every ring has the same four crates, and a new stage goes into the ring’s stages crate, is declared in the ring’s hub, and shows up in the hub’s manifest. This guide adds a stage called dedupe to the dev ring; the enterprise, HFT and robotics rings, in the profiles repository, work the same way.
1. Decide it is a stage
A stage carries state or sits on the hot path: a router, a pool, a lane, an observer. A stateless transformation with no I/O is a function and belongs in <ring>-functions; a type several layers share and nothing else belongs in <ring>-core. CONTRIBUTING.md states the rule; when in doubt, ask whether the module owns anything between calls.
2. Add the module to the stages crate
mkdir dev_ring/dev-stages/src/dedupe
Create dev_ring/dev-stages/src/dedupe/mod.rs with the file header every source file carries, a crate-style overview (//!) that says what the stage is and what it owns, and the code. Declare it in dev_ring/dev-stages/src/lib.rs:
#![allow(unused)]
fn main() {
pub mod conflation;
pub mod dedupe;
}
Add the stage to the crate overview’s list in the same file, one line, in the same shape as the existing entries.
3. Declare it in the hub
The hub is the ring’s only import point. In dev_ring/dev-hub/src/lib.rs, add the stage to the register_stages! invocation:
#![allow(unused)]
fn main() {
mesh_stage_registry::register_stages! {
functions: [
dev_functions::basket,
dev_functions::core_ops,
],
stages: [
dev_stages::conflation,
dev_stages::dedupe,
],
}
}
The macro emits RING_MANIFEST and imports each listed path, so a path that names no module fails the hub’s build here rather than becoming a string in the manifest. The hub’s manifest test asserts the manifest: update dev_ring/dev-hub/tests/manifest.rs so it lists the new stage; the test is what keeps the manifest and the declaration together.
4. Check the gates
cargo clippy --workspace --all-targets -- -D warnings
cargo test -p dev-stages -p dev-hub
scripts/docs/check.sh
The first two are the code gates. The third fails if the crate overview names something that does not exist or if the ring’s page in the book no longer matches the crate table; regenerate the crate index with scripts/docs/gen-all.sh if the description in Cargo.toml changed.
5. Document it
Add one row to the ring’s table in book/src/architecture/rings/dev_ring.md and to dev_ring/README.md, in the same sentence shape as the existing rows: what the stage is and what it owns. If the stage introduces a term, the glossary gets an entry.
Troubleshoot
How-to guide · for anyone who got an error from `pms`, the daemon, the client or a ring and wants the fix
Start with pms doctor; most environment problems surface there. Every error type in the workspace is listed variant by variant in the error catalogue; the entries below are the ones a user meets from the command line, with their remedies.
| You see | It means | Do this |
|---|---|---|
no mesh-hub daemon found (set PMS_DAEMON or run pms start) | No daemon on the default socket or address. | pms start, or point PMS_DAEMON at unix://<path> or ws://<host:port>. |
another daemon is live at /tmp/pms.sock | A daemon already owns that socket. | Use it, or start yours with a different --uds-path and --ws-bind. |
the socket at /tmp/pms.sock is served by user 502, not by this user | Another user’s process created the default socket, and the client will not send to it. | If it is stale, remove it and pms start; otherwise start your daemon with another --uds-path and set PMS_DAEMON=unix://<that path>. |
the daemon did not answer attach within 5s | The daemon accepted the connection but is not answering. | Check its log (MESH_HUB_LOG=debug); what holds it shows there. |
daemon error [name_in_use] | Another attached client holds that name. | Names are released on detach or disconnect; pick another, or find the client holding it with pms tap --json (the origin field). |
daemon error [limit_exceeded]: subscription limit reached: a connection may hold 64 (--max-subscriptions); unsubscribe one first | This connection already holds as many subscriptions as the daemon lets one connection hold. | Unsubscribe one, which frees a place, or raise --max-subscriptions on the daemon. |
daemon error [limit_exceeded]: connection limit reached: the daemon holds at most 256 connections (--max-connections); try again later on attach over the Unix socket | The daemon is at its connection cap and turned the connection away with that one error frame before closing the socket; the Rust client reports the daemon’s reason to the attach. The daemon logs connection refused: the daemon is at its connection limit at the first refusal and at each doubling. | Wait for a connection to close, or raise the flag; the daemon’s exit line counts connections_refused. |
transport error: ws connect …: HTTP error: 503 Service Unavailable | The daemon is at --max-connections; the WebSocket upgrade was refused with 503. | The same. |
daemon error [bad_request]: payload was not valid base64: … | The send payload is not padded standard base64: whitespace or a line break, the URL-safe alphabet, missing or misplaced padding, or a last symbol with stray bits; the message names the byte and its offset. Nothing was published and the connection is still open. | Encode with the standard alphabet and padding, as Python’s base64.b64encode, Node’s Buffer.toString('base64') and the browser’s btoa do. |
daemon error [payload_too_large]: frame of N bytes exceeds the M byte limit | The frame exceeded --max-message-bytes (1 MiB by default). The daemon answered and closed the connection, so the call that sent the frame, every call still waiting and Mesh::wait all report this reason. | Send less per envelope, or raise the flag on the daemon, then attach again. |
Mesh calls block and cannot run inside an async runtime | A Mesh method was called from a tokio task. | Call it from spawn_blocking, or from the callback thread, which is allowed. |
pms: the connection to mesh-hub ended: the daemon connection ended without a detach | The daemon went away, by a signal or a crash (its log ends with an exit line, or stops), and the connection ended without a detach; pms attach and pms tap print this and exit with status 2. A daemon that closed for a reason it stated, limit_exceeded or payload_too_large, prints that reason instead. | Restart the daemon, then the client. |
| WebSocket upgrade refused with 400 | The client did not announce pms-universal-port-v0. | Send Sec-WebSocket-Protocol: pms-universal-port-v0; the thin clients and the Rust client do. |
shared-memory object is N bytes but the requested capacity needs M | The object at that name is smaller than the ring or store the caller asked to map. | Open the ring by name alone: pms ring inspect and PulseRing::open_existing take the geometry from its header. |
shared-memory object is not a formatted ring: magic … | The object at that name is not a ring, or its creator never finished formatting it; a ring created before the header layout carries no magic. | Remove it, or start a daemon under that hub name, which replaces what it finds. |
ring header capacity mismatch: the region has N, the caller expects M (or version, slot_size) | The ring’s header records other geometry than the caller asked for. | Open it by name alone, or match the creator’s capacity and slot size. |
a ring of N slots cannot be made: it needs at least one slot, and its size in bytes must fit a file offset | A ring constructor was given a capacity of zero or one too large to size. Nothing was created. | Pass at least one slot. |
an envelope store of N slots cannot be made or opened: it needs at least one slot, at most M slots, and a size in bytes that fits a file offset | A store constructor or open was given a capacity of zero, above the header’s 32-bit field, or too large to size. Nothing was opened, created or mapped. | The same. |
could not reserve N bytes of shared memory: posix_fallocate failed with errno E | Linux could not reserve the pages of a new ring, store, region or NUMA backing: errno 28 (ENOSPC) means the tmpfs behind /dev/shm is full, errno 12 (ENOMEM) a memory limit, and errno 4 (EINTR) that signals interrupted all eight attempts the creator makes. Nothing was mapped and the half-made object was removed. | Give /dev/shm room: Run the daemon as a service gives the size each hub reserves and the container flag. A daemon whose store has no room logs it and runs without persistence. |
read-only hub: only the hub that created the ring publishes into it; send to the daemon over the port protocol instead | This process embedded a hub under a name whose ring another hub created, the daemon’s for instance, and a ring has one writer; publish, broadcast_envelope and route_envelope are refused before anything is written, while polling, subscribing and listening work. | Publish by sending to the daemon over the port protocol (Mesh::send), or make this process the one that creates the hub. |
envelope-store magic mismatch or envelope-store version mismatch | A store object from an older layout is still present under that name. | Start a daemon under that hub name: it unlinks what it finds and creates its own. On Linux just clean also removes /dev/shm/pms-*. |
| A tap prints nothing while pushes succeed | The filter does not match, or events were dropped: the daemon drops for a connection that falls behind, and the Rust client drops for a callback that cannot keep up. | Check the channel glob. The unsubscribe ack’s events_dropped counts what the daemon dropped for that subscription; Mesh::events_dropped() counts what the client dropped, and with_event_queue sets how many may wait. |
pms start says the daemon did not become ready | mesh-hub did not bind within the timeout. | Read the log path the message prints; a stale socket or a bound port is the usual cause. |
When the fix is not here
The daemon design and trust boundaries pages say what the daemon will and will not do; the specification’s port protocol chapter has every error code. Refutations and gaps are defects in this page; report them.
What is exactly true
What is exactly true
The port protocol, the workspace crate by crate, and the tables generated from the code.
Part opener · for implementers and integrators looking something up
Reference pages state what is true and nothing else. They do not teach and they do not persuade. Where a table can be produced from the code, it is, and a check in continuous integration fails when the page and the code disagree.
The part has four layers. The specification is the standard’s; its chapter 7, the port protocol, is here because the daemon is what speaks it, and the golden fixtures under mesh/mesh-port-protocol/fixtures are its executable form. The workspace pages describe the tier and the ring, following the tree’s own structure. The API page describes the Rust client. The generated pages are produced from the code by the gate.
- The specification: the chapters, and chapter 7, the port protocol.
- Workspace: Layout, Fabric: mesh/, the dev ring, the leaves beside them. The standard’s tiers and the other rings have their pages in the standard’s book and the profiles’ book.
- Generated from the code: the pms command line, the mesh-hub daemon, the MCP tools, the modules and developer profiles, the crate index and the error catalogue; the gate regenerates them and fails on any difference.
- API: Overview and Rust; the codec bindings for Python, Node and C are in the standard’s book.
- Glossary.
The specification
The specification
The normative description of the Pulse, the PulseEnvelope, the ring, the store, the UDP transport and the port protocol, and what a conformant implementation must reproduce.
Reference · normative · for anyone implementing, verifying or extending Pulse Mesh in any language
This is the Pulse Mesh Standard as the reference implementation in this repository realises it. Where a chapter describes the reference implementation rather than a requirement on every implementation, it says so at the top.
Status. Working draft, lineage draft-00. Breaking changes between draft revisions are expected and are recorded, not hidden: a superseded rule stays in the text, marked as superseded, with the revision that replaced it. The conformance vectors under pulse/pulse-spec/test-vectors are the executable form of chapters 2 and 3; the chapter text and the vectors are checked against the code by the documentation gate and the workspace tests.
How to read the requirement words. MUST, MUST NOT, SHOULD, SHOULD NOT and MAY carry their usual normative meaning. A statement without one of those words is a description of the reference implementation, not a requirement on others.
| Chapter | Subject | Normative for |
|---|---|---|
| 1. Introduction | Scope and terminology | Everyone |
| 2. The Pulse | The 128-byte atom: layout, alignment, instructions, the heartbeat overlay | Every implementation |
| 3. The PulseEnvelope | The canonical bytes, the content id, payload variants, recursion, self-verification | Every implementation |
| 4. The SeqLock ring | The shared-memory ring: header, slots, writer and reader protocol, crashed writers | Shared-memory substrates |
| 5. The envelope store | The content-addressed store beside the ring: layout v2, claims that route around held slots, verified reads, lapping | The reference implementation |
| 6. The UDP transport | Datagram framing, CRC, feedback, FEC, the capability handshake | The reference transport |
| 7. The Universal Port Protocol | The daemon’s JSON protocol over WebSocket and Unix socket, and its connection policy | Every client of the daemon |
| 8. Conformance | What a conformant implementation reproduces, and what changes require a draft revision | Everyone |
| Appendix A. BLAKE3 | The hash the content id is built on | Informative |
7. The Universal Port Protocol
Reference · normative for the daemon's clients · the JSON protocol between `mesh-hub` and anything that attaches to it, over a WebSocket or a Unix socket
The daemon terminates one protocol on two transports. A client attaches under a name, publishes envelopes to channels, subscribes to filtered streams, and detaches; the daemon answers each request, pushes events, and enforces a connection policy that keeps a silent, slow or oversized peer from costing the others anything. The types are defined once, in mesh-port-protocol, and the daemon and the Rust client both use them; the thin Python and Node clients and the website’s browser client speak the same JSON. Version v0.
7.1 Transports and framing
| Transport | Endpoint | Framing |
|---|---|---|
| WebSocket | ws://127.0.0.1:9876 by default; the client MUST announce the subprotocol pms-universal-port-v0 in Sec-WebSocket-Protocol, and the daemon echoes it or refuses the upgrade with 400; at the daemon’s connection cap the upgrade is answered 503 Service Unavailable. | One JSON object per text frame. |
| Unix socket | /tmp/pms.sock by default, created with owner-only permissions. At the daemon’s connection cap the client receives one unprompted limit_exceeded error frame naming --max-connections, and the socket closes. | A 4-byte big-endian length prefix, then that many bytes of JSON. A prefix that declares more than the size limit is refused as soon as the prefix is complete, and nothing is allocated for it; the daemon’s transport and the Rust client drive one sans-IO decoder, so a read cancelled between pieces loses nothing. |
Both carry the same message stream. Every message is a JSON object whose op field names the kind. The frames of every kind, each optional field present in one frame and absent in another, are pinned under mesh/mesh-port-protocol/fixtures/ (client.json, server.json) as the Rust types serialize them; the crate’s tests keep the files current, and the Python and Node thin clients’ tests decode every server frame from them and compare the frames they write with the client frames as parsed JSON. A daemon MUST refuse to bind a Unix socket path where another daemon is live (it probes the socket first and removes only a stale file), so a second daemon never steals a live one’s clients.
7.2 Client messages
op | Fields | Meaning |
|---|---|---|
attach | id?, name, protocol_version, capabilities? | Register the connection under name. protocol_version MUST be "v0". capabilities lists the surfaces the client wants; v0 recognises send, subscribe and tap. |
detach | id? | Release the name and every subscription, then close. |
send | id?, channel, type_id?, type_name?, payload, flags? | Publish one envelope: payload is standard base64 (RFC 4648 §4: the A–Z, a–z, 0–9, +, / alphabet, padded with = to a multiple of four characters, nothing else in the text); anything else, whitespace, the URL-safe alphabet, missing or misplaced padding or a last symbol with stray bits, is refused with bad_request saying what is wrong and where, nothing is published, and the connection stays open. type_id defaults to 0 and type_name routes by name; flags defaults to 0. |
subscribe | id?, filter? | Open a server-pushed stream. filter is a FilterSpec; an empty filter admits everything. |
unsubscribe | id?, subscription_id | Close one stream. |
health | state? | A liveness signal from the client; the daemon records it and does not reply. |
id is echoed on the matching ack or error; a request without one gets a reply without one. A FilterSpec has six optional members that AND-combine: channel (exact or glob, db/*, **/foo), type_id, type_name (exact or glob), origin (the producer’s name@suffix), health (also stream peer state changes), and snapshot (reserved; ignored by v0). Fields at their defaults are omitted on the wire.
7.3 Daemon messages
op | Fields | Meaning |
|---|---|---|
ack | id?, then the request’s fields flattened into the same object | Success. After attach: node_id, protocol_version, granted_capabilities. After send: envelope_id, the content identifier as 32 hexadecimal characters, and, when the daemon has an envelope store, persisted: true when the store kept the envelope, false when it refused it (larger than a store entry, or no slot free), which the daemon counts and logs; delivery to subscribers does not depend on it. After subscribe: subscription_id. After unsubscribe: events_dropped when any event was dropped for that stream (7.5). After detach: the extension key __detach: true. |
error | id?, code, message | Failure. code is one of 7.4; message is for logs, not for branching. |
event | subscription_id, envelope_id, channel, type_id, type_name?, origin, captured_at_unix_nanos, payload | One envelope delivered to one subscription. payload is the same padded standard base64; captured_at_unix_nanos is a u64. |
health | node_id, state, since_unix_nanos | A peer’s state changed; sent on subscriptions whose filter set health. |
A client MUST ignore an op it does not know and an ack key it does not know, so a later revision can add either without breaking it; the existing clients do.
7.4 Error codes
An error that answers no request, one without an id or with one no request is waiting on, is the daemon’s reason for the close that follows it, and a client reports it to every call still waiting and to whatever waits for the connection’s end; a daemon that carries on after such an error, as after bad_request for a frame it could not parse, fails no call by it.
| Code | When |
|---|---|
version_unsupported | The client announced a protocol version other than v0. |
not_attached | The operation needs an attach that has not happened on this connection, or the connection is already attached. |
name_in_use | Another attached connection holds the name. The name is released when that connection detaches or closes. |
bad_filter | The subscribe filter could not be interpreted. |
unknown_subscription | The id is not a subscription of this connection. |
payload_too_large | The frame exceeded the connection policy’s size limit; the daemon closes the connection after the reply. |
limit_exceeded | A connection-policy limit refused the request. On subscribe: the connection holds --max-subscriptions already; it stays open and an unsubscribe frees a place. Unprompted on a Unix socket: the daemon is at --max-connections, and it closes the connection after the frame. The message names the limit and its flag. |
bad_request | The message was malformed JSON, missed a required field, attached a connection twice, or carried a payload that is not padded standard base64; the message says what is wrong and, for base64, at which offset. |
internal | The daemon failed while handling the request. |
The set grows: a later daemon may send a code this table does not list. A client keeps such a code as the text it arrived in and treats the reply as a refusal it cannot classify; the Rust client’s DevError::Daemon { code, .. } holds the string, and mesh_port_protocol::ErrorCode::parse returns None for it.
7.5 The connection policy
The daemon enforces eight limits, four timers and four sizes, each a command-line flag with a default; zero disables a timer, and zero on either cap refuses everything:
| Limit | Default | Effect |
|---|---|---|
--ping-interval-secs | 30 | The daemon pings a WebSocket client this often; a pong counts as activity. The Unix socket has no ping frame. |
--idle-timeout-secs | 90 | A connection with no inbound frame and no pong for this long is closed. A Unix-socket client therefore MUST send something within the window; the Rust client sends a health frame every 30 seconds while idle. |
--write-timeout-secs | 10 | An outbound write that takes longer than this closes the connection as unreadable, so a peer that stops reading cannot stall the daemon. |
--handshake-timeout-secs | 5 | A WebSocket upgrade that does not complete in this time is dropped. |
--max-message-bytes | 1,048,576 | The largest frame accepted on either transport; a larger one gets payload_too_large and the connection closes. A 64 KiB envelope is about 88 KiB of base64 plus its JSON. |
--subscriber-queue | 1,024 | Events queued for one connection. When the queue is full the daemon drops the newest event, counts it, logs at the first drop and at each doubling, and reports the count as events_dropped in that subscription’s unsubscribe ack. A slow subscriber therefore loses events rather than slowing publishers or other subscribers. |
--max-subscriptions | 64 | Subscriptions one connection may hold at once. Every send is matched against every live subscription, so the cap bounds what one connection adds to the cost of each send; the subscribe beyond it gets limit_exceeded, the connection stays open, and an unsubscribe frees a place. |
--max-connections | 256 | Connections the daemon holds at once across both transports, counted from accept (an upgrade in progress counts) until the connection’s task ends. Beyond it a Unix-socket client gets one limit_exceeded frame and is closed, and a WebSocket upgrade is answered 503; refusals are counted (connections_refused), logged at the first and at each doubling, and printed in the exit log. |
Acknowledgements and errors are never dropped; only events are subject to the queue. When a connection ends for any reason the daemon releases its name and closes its subscriptions at once.
7.6 A session
→ {"op":"attach","id":"1","name":"worker","protocol_version":"v0","capabilities":["send","subscribe","tap"]}
← {"op":"ack","id":"1","node_id":"worker@3f9a…","protocol_version":"v0","granted_capabilities":["send","subscribe","tap"]}
→ {"op":"subscribe","id":"2","filter":{"channel":"jobs/*"}}
← {"op":"ack","id":"2","subscription_id":"sub-default_hub-00000001"}
→ {"op":"send","id":"3","channel":"jobs/new","payload":"aGVsbG8="}
← {"op":"ack","id":"3","envelope_id":"9c1d…","persisted":true}
← {"op":"event","subscription_id":"sub-default_hub-00000001","envelope_id":"9c1d…","channel":"jobs/new","type_id":0,"origin":"worker@3f9a…","captured_at_unix_nanos":1758700000000000000,"payload":"aGVsbG8="}
→ {"op":"unsubscribe","id":"4","subscription_id":"sub-default_hub-00000001"}
← {"op":"ack","id":"4"}
→ {"op":"detach","id":"5"}
← {"op":"ack","id":"5","__detach":true}
7.7 What would refute this chapter
The protocol crate’s round-trip tests pin every message shape; the daemon’s tests attach, publish, subscribe and detach over both transports, refuse a second daemon on a live socket, close a silent connection after the idle window and a stalled one after the write window, keep a pinged client attached, reject an oversized frame with payload_too_large, show events_dropped in the unsubscribe ack of a subscriber that stopped reading, acknowledge a send the store refused with persisted: false while still delivering it to its subscribers, refuse a send whose payload is not padded standard base64 without publishing it, refuse the subscription past the cap with limit_exceeded and admit one again after an unsubscribe, and turn away the connection past the cap on both transports. A client that receives bytes this chapter does not describe, or a daemon that keeps a silent connection past its window, contradicts it.
Workspace Layout
Reference · the fabric, the dev ring and the leaves beside them, for anyone finding their way around the tree
This repository holds tier 2 of the Pulse Mesh stack and one tier-3 ring. A tier depends only on the tiers below it: everything here depends on the standard, tiers 0 and 1, which is a sibling checkout (../pulse-mesh) while developing and a pinned tag at a release. Nothing in the standard depends on anything here.
| Tier | Path | What lives here |
|---|---|---|
| 2 — fabric | mesh/ | mesh-hub (the daemon) and mesh-port-protocol (the JSON wire types it speaks, with their golden fixtures), mesh-hub-runtime (the embedded hub the daemon wraps), mesh-client (the Rust client), mesh-basket (the SQLite capture store), mesh-broadcast and mesh-health (the registries), and mesh-module-hub (pre-integration, see the crate index) |
| 3 — the dev ring | dev_ring/ | the general-purpose ring, four crates: dev-core (an empty placeholder that keeps the shape), dev-functions (the ring inspector and the module table), dev-stages (the conflation router) and dev-hub (the composition surface, which re-exports the Rust client and declares the ring through register_stages!) |
Beside the tiers sit the leaves: they consume the tiers and are consumed by nothing.
| Path | What lives here |
|---|---|
tools/ | pms (the daemon’s command line: start, attach, push, tap, demo, doctor, and the codec, UDP, version and bench commands it takes from the standard’s pulse-cli), pms-mcp (the MCP server), pms-telemetry-export (the telemetry exporter), and pms-query, a Python tool over the basket |
clients/ | the Python and Node thin clients, which speak the port protocol over a WebSocket without the codec (Two programs talk through the daemon runs them) |
examples/ | the two-process example and the multi-language one, both run by Two programs talk through the daemon |
fuzz/ | the cargo-fuzz harness over the daemon’s parsers of untrusted bytes; not a workspace member: cargo +nightly fuzz run <target> --fuzz-dir fuzz runs one, and just check-fuzz builds them all |
The other rings
The enterprise, HFT and robotics rings are the profiles, a repository built on the standard beside this one. Every ring has the same four crates, and the dev ring here is one of them; Rings: domain surfaces describes the shape.
Deciding where new code goes
CONTRIBUTING.md frames this as a four-question test: does the code touch the canonical Pulse atom, touch the canonical envelope, cross a ring boundary, or sit on the hot path? Code that touches the atom or the envelope belongs in the standard, and is made there first. Code that is part of the daemon’s contract (the port protocol, routing, the client) belongs in mesh/. A stage that crosses a ring boundary belongs in a ring: the dev ring here, or a profile. Anything that only uses the tiers (a tool, a client, an example) is a leaf here. Record any non-obvious classification call as a new ADR under decisions/ rather than only justifying it in the PR.
Fabric: mesh/
Reference · the fabric tier, crate by crate
Routing and registration: the daemon, the port protocol it speaks, the embedded hub it wraps, the Rust client, and the broadcast, health, schema and basket crates around them. The vocabulary crates the fabric registers against (the Lexicon, the context and capability registries) are the standard’s, under its pulse/ tier. See also mesh/README.md in the tree.
| Crate | Purpose |
|---|---|
mesh-basket | Basket — SQLite-backed capture of every envelope the daemon publishes, with a small query DSL (channel, time-window, type and origin filters, aggregations, pagination) and a retention sweep. Queried by the pms-mcp tools and by tools/pms-query, and re-exported as dev_functions::basket so the dev ring keeps its historical path. |
mesh-broadcast | BroadcastRegistry — an in-process listener registry that fans out envelopes to callbacks matching a (NodeId, ListenFilter) pair, indexed by node and by type for fast dispatch. |
mesh-client | The Rust client for the daemon: Mesh, a synchronous handle over the Universal Port Protocol with endpoint discovery, timed calls, bounded queues in events and bytes, and callbacks on a thread of its own. It depends on mesh-port-protocol and the async stack only, so a program that only talks to a daemon compiles none of the daemon. |
mesh-health | HealthRegistry — per-node heartbeat tracking with miss-count escalation: heartbeat() resets the miss counter, tick() advances it and returns transitions through Healthy → Warning → Restarting → Isolated. Degraded is a separate side-channel state, set explicitly via record_drift(). |
mesh-hub | The local daemon — binary name mesh-hub. Terminates the Universal Port Protocol over a Unix domain socket and WebSocket, bridging connections to mesh-hub-runtime::PulseHub. Runs until SIGINT/SIGTERM with graceful shutdown; it creates its shared-memory ring and store afresh at start and unlinks them at exit. Its flags are The mesh-hub daemon. |
mesh-hub-runtime | The embedded event bus the daemon wraps. PulseHub combines an EnvelopeStore (content-addressed persistence), a PulseRing (signaling), a SchemaRegistry + BroadcastRegistry (routing), and a HealthRegistry (liveness). Envelopes cross processes through the store: a hub that broadcasts an envelope stores it under its identifier and publishes a Pulse that names it. |
mesh-module-hub | Pre-integration (ADR-0014): the daemon runs no module hub yet. ModuleHub — bridges a node’s internal processing to a parent PulseHub: collects inbound envelopes from a parent hub listener and provides an outbox for emitting results back upstream. |
mesh-port-protocol | The Universal Port Protocol v0 wire types (ClientMessage, ServerMessage, FilterSpec, AckFields, ErrorCode and the default endpoints), shared by the daemon and the client so the protocol is defined exactly once and every type serialises in both directions. It also holds the port’s golden fixtures, fixtures/client.json and fixtures/server.json, written by the Rust types and held against the thin clients. Chapter 7 is its normative text. |
mesh-schema | The Schema Registry — a runtime ACL layer tracking which nodes may produce or consume which envelope types, and the check the hub runtime puts every broadcast envelope through: a schema governs the numeric type it is registered for, and a type name routes an envelope whose type has none. A sibling of the standard’s mesh-lex: mesh-lex is the published, content-addressed catalog; mesh-schema is one mesh’s runtime validation layer, which can be stricter or looser than what the catalog publishes. |
mesh-hub is the only crate in this tier with a binary. Everything else here is a library, consumed either directly by the daemon (via mesh-hub-runtime) or by the dev ring’s dev-hub.
dev_ring
Reference · the dev ring, crate by crate
General-purpose developer ergonomics ring. Downstream code should depend on dev-hub, not the inner three crates directly.
| Crate | Layer | Contents |
|---|---|---|
dev-core | core | Empty by design. Everything in the dev ring is a function, a stage, the hub or peripheral tooling; the crate keeps the four-crate shape uniform and is where shared core types land. |
dev-functions | functions | basket — re-export of the tier-2 mesh-basket crate (SQLite-backed envelope capture), kept at its historical path. core_ops — the ring inspector and the module table; the codec, UDP, version and bench operations are the standard’s pulse-cli since the split, which pms reaches through it. |
dev-stages | stages | conflation — a symbol-keyed last-write-wins conflation router wrapping a PulseRing for O(1)-per-symbol latency. |
dev-hub | hub | The ring’s import point: re-exports core, functions and stages, emits the ring manifest via register_stages!, and re-exports the Rust client Mesh from mesh-client, its home since ADR-0012, so dev_hub::Mesh keeps working. |
If you’re writing an example or a peripheral tool that just needs to talk to a running mesh-hub daemon, mesh-client’s Mesh API (reachable as dev_hub::Mesh too) is almost certainly the right entry point — Two programs talk through the daemon is built on exactly this.
Peripheral surface
Reference · everything beside the tier and the ring, directory by directory
Everything that falls outside the fabric and the dev ring (per the four-question test in Workspace Layout) sits beside them at the top of the repository. The thin clients and the fuzz harness opt out of the root Cargo workspace: the clients have no Rust to build, and the harness builds on nightly with cargo-fuzz.
| Directory | Contents |
|---|---|
tools/ | Developer-facing binaries: pms (the daemon’s command line, see The pms command line; its codec, UDP, version and bench commands are the standard’s pulse-cli, which it depends on), pms-mcp (the MCP server, see The MCP tools), pms-telemetry-export (the telemetry exporter), and pms-query, a Python query tool over the basket the daemon can capture, with its own tests under tools/pms-query/tests. |
clients/ | The pure-language thin clients with no compilation step, pms-py-thin and pms-node-thin, which talk to a running mesh-hub daemon over a WebSocket speaking the port protocol directly. Each holds its own tests, held to the golden frames under mesh/mesh-port-protocol/fixtures; scripts/thin-e2e.py runs both against a live daemon. Two programs talk through the daemon uses them. |
examples/ | Runnable demos: multi-process (two processes talking through a mesh-hub daemon, the program Two programs talk through the daemon includes) and multi-lang (a Python publisher and a Node subscriber talking through the same daemon, over the thin clients). |
fuzz/ | A cargo-fuzz harness over the daemon’s parsers of untrusted bytes: the port protocol’s base64 payloads, its Unix-socket framing and its JSON messages, an envelope’s origin, and the client’s routing of what a daemon sends. Not a workspace member: from the repository root, cargo +nightly fuzz run <target> --fuzz-dir fuzz runs one, and just check-fuzz builds them all. |
The standard’s leaves, the pulse command line, the codec bindings, its examples and its fuzz harness over the wire and the codec, are in its book; the profiles’ plugin and telemetry tool are in theirs.
The pms command line
Reference · generated · every subcommand and flag of `pms`, printed by the binary itself
pms
Sends, receives, encodes, and inspects pulses across the local mesh. Run pms demo first to see a producer + consumer talk through the mesh in one terminal.
pms doctor
Verify the local toolchain and mesh runtime preconditions
| Argument | Default | Meaning |
|---|---|---|
--for <FOR> | Filter checks by developer profile (e.g. hft, ai, robotics) |
pms send
Send pulses to a UDP destination
| Argument | Default | Meaning |
|---|---|---|
<DEST> | required | Destination address (e.g. 127.0.0.1:9000) |
--count <COUNT> | 1 | Number of pulses to send |
--payload <PAYLOAD> | 16 | Envelope payload size in bytes |
pms recv
Bind a UDP address and print received pulses
| Argument | Default | Meaning |
|---|---|---|
<BIND> | required | Local bind address (e.g. 127.0.0.1:9000) |
--count <COUNT> | 1 | Maximum pulses to receive before exiting |
--timeout-ms <TIMEOUT_MS> | 5000 | Per-recv timeout in milliseconds |
--json | Emit JSON instead of human-readable output |
pms version
Print workspace + codec version stamp
| Argument | Default | Meaning |
|---|---|---|
--json | Emit JSON instead of human-readable output |
pms encode
Encode a JSON envelope spec and report the canonical wire bytes
| Argument | Default | Meaning |
|---|---|---|
--json <FILE> | required | Path to a JSON spec file (schema: EnvelopeSpec in pulse_cli::commands::encode_decode) |
--json-out | Emit JSON instead of human-readable output |
pms decode
Decode canonical wire hex back into an envelope summary.
Exits with status 1 when the envelope does not verify: its content does not produce the identifier it carries.
| Argument | Default | Meaning |
|---|---|---|
<HEX> | required | Hex string (whitespace trimmed) |
--json | Emit JSON instead of human-readable output |
pms ring
Snapshot a SHM-backed PulseRing (read-only secondary reader)
pms ring inspect
Snapshot ring header + the N most-recent slot summaries
| Argument | Default | Meaning |
|---|---|---|
<NAME> | required | Hub name (e.g. default_hub) or a raw POSIX SHM name starting with /; the ring’s header gives its capacity |
--count <COUNT> | 8 | Number of most-recent slots to summarize |
--json | Emit JSON |
pms modules
List the M01..M12 optimization modules, optionally by profile
pms modules ls
List modules, optionally filtered by developer profile
| Argument | Default | Meaning |
|---|---|---|
--for <PROFILE> | Filter by profile (hft, ai, robotics, telemetry, dev) | |
--json | Emit JSON |
pms bench
Throughput and latency of pulses over UDP loopback on this host
pms bench smoke
Throughput and latency of pulses over UDP loopback on this host: the same run as pulse bench
| Argument | Default | Meaning |
|---|---|---|
--count <COUNT> | 10000 | Number of pulses to send through the loopback |
--json | Emit JSON |
pms start
Start the local mesh-hub daemon (UDS + WebSocket).
In the background (the default) it waits up to 60 s for the daemon to serve, and stops one that does not (SIGTERM, then SIGKILL 5 s later) before it reports the failure and exits 1.
| Argument | Default | Meaning |
|---|---|---|
--foreground | false | Run in the foreground (block until ^C) |
--basket-path <BASKET_PATH> | Optional basket path for envelope capture |
pms attach
Attach to a running mesh-hub daemon under the given name and park
| Argument | Default | Meaning |
|---|---|---|
<NAME> | required | Process-visible name to register under |
pms push
Publish a one-shot envelope to a channel
| Argument | Default | Meaning |
|---|---|---|
<CHANNEL> | required | Routing key |
<PAYLOAD> | required | Payload as a UTF-8 string. Use --hex for raw bytes via hex |
--hex | Interpret payload as hex-encoded bytes | |
--type-name <TYPE_NAME> | Optional type name |
pms tap
Subscribe to a channel and print events live
| Argument | Default | Meaning |
|---|---|---|
--channel <CHANNEL> | Channel filter (exact or glob). Default: all | |
--type-name <TYPE_NAME> | Type-name filter | |
--json | One JSON object per event instead of the table format |
pms hello
Spin up a 2-thread demo (send + recv on channel hello)
pms demo
One-command end-to-end smoke loop: auto-starts the daemon if needed, runs a 5-message producer/consumer demo, cleans up.
It exits 1, saying how many arrived, when fewer than all five messages reach the consumer within 5 s. With PMS_DAEMON set it uses the daemon that names and starts none.
pms keys
Make and read the Ed25519 keys a client proves at attach.
A key file is its owner’s alone (mode 0600), and a client refuses one its group or others may read or write. Each subcommand prints the key’s public half as a line of a daemon’s --attach-keys list: its standard base64, 44 characters.
pms keys generate
Write a new key to a new file, readable and writable by its owner alone, and print its public key.
Exits 1, replacing nothing, when the file exists or cannot be written.
| Argument | Default | Meaning |
|---|---|---|
<PATH> | required | The key file to create |
pms keys public
Print the public key of a key file.
Exits 1 when the file cannot be read, holds no key, or its group or others may read or write it.
| Argument | Default | Meaning |
|---|---|---|
<PATH> | required | The key file to read |
pms docs
Print the generated reference pages the book carries
pms docs cli-markdown
The pms command tree as Markdown, from the definitions that parse the real arguments
The mesh-hub daemon
Reference · generated · every flag of the daemon with its default, printed by the binary itself
mesh-hub
Pulse Mesh local daemon — Universal Port Protocol terminator over UDS + WebSocket
| Argument | Default | Meaning |
|---|---|---|
--hub-name <HUB_NAME> | default_hub | The hub name, which names its shared memory (/pms-<name> and /pms-<name>-envelopes): 1 to 15 characters from letters, digits, _, . and - |
--store-slots <STORE_SLOTS> | 256 | Slots in the envelope store that keeps each envelope for the processes attached to the hub by name, 65,600 bytes of shared memory each: 0 (no store) to 4,294,967,295 |
--uds-path <UDS_PATH> | /tmp/pms.sock | Unix-domain socket path, at most as long as a socket address holds (103 bytes on macOS, 107 on Linux) |
--ws-bind <WS_BIND> | 127.0.0.1:9876 | WebSocket bind address (host:port): a loopback address, unless --ws-tls-cert and --attach-keys are both given, so that the WebSocket is encrypted and its clients prove their keys, or --ws-allow-remote is |
--ws-allow-remote | Let --ws-bind take an address other than loopback without TLS and attach keys both. Without --attach-keys the WebSocket authenticates no client, so anything that can reach the address can attach, send and subscribe; without --ws-tls-cert nothing encrypts it, and a proof made at attach can be relayed as it is made. The daemon logs a warning when it binds such an address, naming what is missing | |
--ws-tls-cert <PEM> | A PEM file of the certificate chain the WebSocket presents, its own certificate first. With --ws-tls-key (both or neither), the WebSocket is wss://: each connection completes a TLS 1.3 handshake before its upgrade, the two within --handshake-timeout-secs. TLS encrypts the WebSocket and authenticates the daemon to clients that trust the certificate; --attach-keys authenticates the clients, each proof bound to its TLS connection | |
--ws-tls-key <PEM> | A PEM file of the private key of --ws-tls-cert’s certificate (PKCS #8, PKCS #1 or SEC1); both or neither | |
--attach-keys <FILE> | A file of the Ed25519 public keys a client may prove at attach: one key’s standard base64 per line, as pms keys public prints it, with # comments and blank lines. With it, an attach on the WebSocket must carry a listed key and answer the daemon’s challenge with a proof of it within --handshake-timeout-secs, and is named by the key; one without a key, a proof that fails, or a key not listed is refused with auth_failed, and the connection closed. The Unix socket, which only the daemon’s user can open, still admits an attach without a key unless --attach-keys-uds is given. A file that cannot be read, or holds a line that is not a key, stops the start (exit status 1) | |
--attach-keys-uds | Require a listed key, proved at attach, on the Unix socket too; needs --attach-keys | |
--metrics-bind <ADDR> | Serve the daemon’s counts at GET /metrics on this address (host:port), in the Prometheus text format; off unless given. A loopback address unless --metrics-allow-remote is given | |
--metrics-allow-remote | Let --metrics-bind take an address other than loopback. The endpoint has no authentication, so anything that can reach the address can read the daemon’s counts; the daemon logs a warning when it binds one | |
--allow-origin <ORIGIN> | A web origin, scheme://host[:port], whose pages may open the WebSocket besides pages served from localhost, 127.0.0.1 or [::1] (repeatable); other pages’ upgrades get HTTP 403, and clients that send no Origin are admitted | |
--node-key <FILE> | The file of this daemon’s node key, the X25519 key its links to other daemons authenticate with: loaded, or, when there is no file, generated and saved with mode 0600. A file its group or others can read or write is refused. Links need it, --link-bind and at least one peer (--link-peer or --link-peer-key), all three or none. The daemon logs at startup its daemon id (the fingerprint of the key’s public half, which names it across links), the public half, which its peers configure, its link address and its peers | |
--link-bind <ADDR> | The UDP address (an IP address and port) every link to another daemon is sent from and received on: one sealed endpoint (Noise IK sessions, ChaCha20-Poly1305), whatever the number of peers. Links are off unless it is given, with --node-key and a peer | |
--link-peer <ADDR=PUBKEY> | A daemon to link to, as ADDR=PUBKEY: the IP address and port of its --link-bind, and the public half of its node key as 64 hex digits (repeatable). Only the peers’ keys are admitted, each of these from its address’s IP. A client’s send is forwarded, once, to each linked peer whose subscribers may want it, and what a peer forwards reaches the subscribers here, its origin name@ and the sending daemon’s id | |
--link-peer-key <PUBKEY> | A daemon to link to named by the public half of its node key alone, as 64 hex digits (repeatable). Its address is learned from its announcements on the --link-announce group, which this needs. Such a peer is admitted from any address its handshake comes from, its key authenticated as any peer’s is, where a --link-peer is admitted only from its address’s IP | |
--link-announce <GROUP:PORT> | An IPv4 multicast group and port, GROUP:PORT (the group’s first byte 224 to 239, and 239 the range a site uses for its own), on which the daemon announces its link endpoint (its node key’s public half and its link port) at startup and every 5 seconds, and hears the other daemons’ announcements; off unless given. An announcement is a plaintext hint, not signed. One from a key that is not a configured peer’s is ignored; one from a configured peer’s key, while no sealed session to it exists, asks for a handshake, which then authenticates the key: at the announced address for a --link-peer-key, and at its own address for a --link-peer, which no announcement changes; at most one such handshake per peer every 5 seconds. Needs --link-bind to be an IPv4 address; the announcements leave by its interface when it is not 0.0.0.0 | |
--link-announce-ttl <TTL> | 1 | How many hops the --link-announce announcements may cross, their IPv4 TTL, 1 to 255. The default, 1, keeps them on the local network |
--link-relay | Relay for the linked daemons (ADR-0021, item 9): forward once more what one peer sent, to the other peers whose subscribers may want it (never back to the peer it came from, nor to its origin’s daemon), and tell each peer what the other peers that are not relays want and which clients they have, so that sends and to sends reach them through this daemon. A send crosses one relay at most: it leaves its origin with a hop limit of two, and a relay spends one. The peers see in its Hello that it relays; whether they accept what it relays is their own --link-trust-relay. Needs links | |
--link-trust-relay <PUBKEY> | A configured peer, named by the public half of its node key as 64 hex digits, whose relayed traffic this daemon accepts (repeatable): sends of other daemons’ clients that it forwards, the clients it announces as reached through it, and their health. From any other peer such traffic is refused and counted. The peer’s link authenticates the peer itself, but the origins it reports for what it relays are only as trustworthy as it is: nothing signs an envelope end to end. The key must be a --link-peer’s or a --link-peer-key. Needs links | |
--no-uds | Disable the UDS transport | |
--no-ws | Disable the WebSocket transport | |
--foreground | true | Run in the foreground, the only mode; use a supervisor (launchd, systemd, nohup) to run it in the background |
--ping-interval-secs <PING_INTERVAL_SECS> | 30 | Seconds between WebSocket pings, at most 31,536,000 (a year); 0 disables them |
--idle-timeout-secs <IDLE_TIMEOUT_SECS> | 90 | Seconds of silence (no frame, no pong) before a connection is closed, at most 31,536,000 (a year); 0 disables the check |
--write-timeout-secs <WRITE_TIMEOUT_SECS> | 10 | Seconds one outbound write may take before the connection is closed as unreadable, at most 31,536,000 (a year); 0 waits forever |
--handshake-timeout-secs <HANDSHAKE_TIMEOUT_SECS> | 5 | Seconds a WebSocket upgrade may take before the connection is dropped, the TLS handshake before it included on wss://, 1 to 31,536,000 (a year) |
--max-message-bytes <MAX_MESSAGE_BYTES> | 1048576 | Largest message accepted on either transport, a WebSocket message counting whole however many frames it comes in: 1,024 to 4,294,967,295 bytes |
--subscriber-queue <SUBSCRIBER_QUEUE> | 1024 | Events queued per connection before newer ones are dropped and counted in the unsubscribe ack, 1 to 1,048,576 |
--subscriber-queue-bytes <SUBSCRIBER_QUEUE_BYTES> | 16777216 | Bytes of event JSON queued per connection, the event being written included, before newer events are dropped and counted; 0 drops every event |
--total-queue-bytes <TOTAL_QUEUE_BYTES> | 268435456 | Bytes of event JSON queued across all connections, shared so that one within its equal share takes room from the one holding the most. A subscriber that keeps up holds at most two events, so it loses no event to this budget while fewer connections hold events than this over twice --max-message-bytes (about 128 at the defaults); past that it can lose events too. 0 drops every event |
--max-subscriptions <MAX_SUBSCRIPTIONS> | 64 | Subscriptions one connection may hold at once, a subscribe beyond it refused with limit_exceeded; 0 refuses every subscribe |
--max-connections <MAX_CONNECTIONS> | 256 | Connections the daemon holds at once across both transports, one beyond it refused (a limit_exceeded frame on the Unix socket, HTTP 503 on the WebSocket); 0 refuses every connection |
--basket-path <BASKET_PATH> | A basket SQLite file to capture every published envelope into, for pms-query and the mesh_query MCP tool | |
--basket-retention-secs <BASKET_RETENTION_SECS> | 60 | Seconds between the basket’s retention sweeps, 1 to 31,536,000 (a year); no effect without --basket-path |
Exit status:
- 0: stopped by SIGINT or SIGTERM.
- 1: could not start, or could not install its signal handlers; the log says why (another daemon holds the socket, the port or the hub name, a store or a basket could not be made or opened, the WebSocket’s TLS certificate or key could not be read or was refused, the –attach-keys file could not be read or holds a line that is not a key, the –node-key file could not be read or made, the –link-bind address could not be bound, or the –link-announce group could not be joined).
- 2: the command line was refused.
- 3: displaced. The hub name’s lock file was removed while the daemon ran and another owner took the name, so the processes attached to the hub by name follow that owner and nothing sent to this daemon reaches them. The daemon notices within 1s, logs at error level what happened and who holds the name now, closes its connections, removes its socket if the path still names it, and exits. A restart under the same
--hub-nameis refused while that owner lives, so a supervisor should not restart the daemon on this status (systemd:RestartPreventExitStatus=3).
The MCP tools
Reference · generated · every tool `pms-mcp` registers, with its input schema, printed by the server itself
The server speaks JSON-RPC 2.0 over stdio. mesh_probe_pulse and mesh_reachability are registered only when PMS_MCP_ALLOW_PROBE=1 is set at launch.
mesh_list_rings
Enumerate POSIX SHM regions that look like Pulse Mesh rings. On Linux this scans /dev/shm/pms-*; on macOS POSIX shared-memory names are not enumerable via the filesystem, so this returns an empty list with a note field rather than failing.
| Input | Type | Required | Notes |
|---|
mesh_ring_peek
Read-only snapshot of an existing SHM-backed PulseRing. Returns capacity, write_count, cursor, and the most-recent N slot summaries. Give a hub name (e.g. default_hub) for the daemon’s ring or a raw POSIX SHM name starting with /; the ring’s header records its capacity and slot size, so none is passed. The reader attaches as a secondary reader — it does not advance the primary cursor.
| Input | Type | Required | Notes |
|---|---|---|---|
count | integer | Max number of recent slots to summarize. | |
ring_name | string | yes | Hub name (e.g. default_hub) or a raw POSIX SHM name starting with /. |
codec_roundtrip
Canonically encode a PulseEnvelope spec, round-trip decode it, and return the canonical hex + BLAKE3 content hash + size. Proves the canonical codec works for the supplied input. The same canonical_hex output is byte-identical across Rust, Python, and Node implementations of the codec, which is the byte equality the standard is built on.
| Input | Type | Required | Notes |
|---|---|---|---|
envelope_type | integer | yes | |
payload_hex | string | Hex-encoded raw payload bytes (mutually exclusive with payload_utf8). | |
payload_utf8 | string | UTF-8 payload bytes (mutually exclusive with payload_hex). | |
type_name | string |
mesh_envelope_describe
Decode a canonical PulseEnvelope hex string and return a structural summary: envelope_type, content_hash, monotonic_nanos, flags, payload variant + size. Pure function — does not touch live mesh state.
| Input | Type | Required | Notes |
|---|---|---|---|
canonical_hex | string | yes | Canonical wire bytes, lowercase hex. |
modules_list
List the M01..M12 optimization modules, optionally filtered to one developer profile (hft / ai / robotics / telemetry / dev). Each entry includes its M-id, crate name, one-line summary, and the profiles it is recommended for.
| Input | Type | Required | Notes |
|---|---|---|---|
profile | string | Optional profile filter. one of [“hft”,“ai”,“robotics”,“telemetry”,“dev”] |
modules_describe
Describe one M01..M12 optimization module by its M-id: the crate or module that implements it, its one-line summary, and the developer profiles it is recommended for. The implementing crate’s rustdoc carries the full documentation.
| Input | Type | Required | Notes |
|---|---|---|---|
id | string | yes | Module id like ‘M04’. |
mesh_attach
Register a process name in the mesh-hub registry. Returns the daemon’s assigned node_id. The connection is dropped at the end of the call; another call (or the dev-functions Rust crate) holds the name persistently if you need that.
| Input | Type | Required | Notes |
|---|---|---|---|
name | string | yes | Process-visible name to register under. |
mesh_send
Publish an envelope to a channel on the mesh-hub daemon. Returns the daemon’s envelope_id.
| Input | Type | Required | Notes |
|---|---|---|---|
as_name | string | Name to attach under for this send. Defaults to ‘mcp-send’. | |
channel | string | yes | Routing key. |
payload | string | UTF-8 payload (use payload_hex for binary). | |
payload_hex | string | Hex-encoded payload bytes; mutually exclusive with payload. | |
type_name | string | Optional type name for typed schemas. |
mesh_query
Structured query against the daemon’s basket SQLite store. Returns rows or aggregations; never a firehose. Supply filters (channel, type, origin, time window) + optional aggregation (count, count_by_channel, time_bucket, distinct_channels, distinct_types). Replaces unbounded mesh_tap streaming.
| Input | Type | Required | Notes |
|---|---|---|---|
aggregate | string | one of [“count”,“count_by_channel”,“time_bucket”,“distinct_channels”,“distinct_types”] | |
basket_path | string | Path to the basket DB. Defaults to $PMS_BASKET_DB or ~/.local/share/pms/basket.db. | |
bucket_secs | integer | Required when aggregate=time_bucket. | |
channel | string | ||
envelope_type | integer | ||
limit | integer | Max rows (default 50, capped at 200 for MCP). | |
origin | string | ||
since_unix_nanos | integer | Time-window start (inclusive). | |
type_name | string | ||
until_unix_nanos | integer | Time-window end (exclusive). |
mesh_health
Report which names are attached to the daemon and basic liveness state. For v0 this returns a static snapshot (no heartbeat protocol yet); future revisions surface NodeState from mesh-health.
| Input | Type | Required | Notes |
|---|
mesh_replay
Replay envelopes from the basket back through the daemon. Useful for re-running a captured workload. WARNING: also reaches subscribers — confirm with the user before using on production-relevant channels.
| Input | Type | Required | Notes |
|---|---|---|---|
as_name | string | Name to attach under. Defaults to ‘mcp-replay’. | |
channel | string | yes | Source channel (basket query). |
limit | integer | Max envelopes to replay (capped at 100). | |
since_unix_nanos | integer | ||
target_channel | string | Where to replay. Defaults to source channel. | |
until_unix_nanos | integer |
mesh_probe_pulse
Send a single pulse to a UDP target and return send-side duration. Bounded: exactly one frame per call. Does not echo / measure RTT — that requires a peer-side echo service not shipped by default. Use this for liveness checks and to surface kernel errors (destination unreachable, etc.). Gated on PMS_MCP_ALLOW_PROBE=1 at server launch.
| Input | Type | Required | Notes |
|---|---|---|---|
instruction | integer | Pulse instruction opcode (default: Heartbeat = 1). | |
payload_hex | string | Optional hex-encoded envelope payload. Empty if omitted. | |
target | string | yes | UDP destination, e.g. 127.0.0.1:9000. |
mesh_reachability
Send a one-pulse Heartbeat probe to each target in turn and report whether the kernel accepted the send. UDP is connectionless so this is a send-success check, not an RTT measurement. Gated on PMS_MCP_ALLOW_PROBE=1.
| Input | Type | Required | Notes |
|---|---|---|---|
targets | array | yes | List of UDP destinations, e.g. [“127.0.0.1:9000”, “10.0.0.5:9001”]. |
Modules and developer profiles
Reference · generated · the twelve optimization modules, and the developer profiles that select them
A developer profile is a tag that pms modules ls --for and pms doctor --for take, and that the MCP modules_list tool takes as profile. It selects modules from the table below and enables nothing: pms modules ls --for lists them, and pms doctor --for adds a line naming them. An unknown profile makes pms modules ls exit 1 and pms doctor report a warning. A developer profile is not the profiles repository (pulse-mesh-profiles), which holds the enterprise, HFT and robotics rings.
The table is static, in dev_ring/dev-functions/src/core_ops/modules.rs, and this page is generated from what pms modules ls --json prints.
Developer profiles
| Profile | Flag | Modules | Rings the modules sit in |
|---|---|---|---|
hft | --for hft | M01, M02, M03, M04, M09, M11 | dev_ring, hft_ring |
ai | --for ai, --for inference | M01, M04, M05, M06, M07, M12 | hft_ring, robotics_ring |
robotics | --for robotics | M03, M04, M08, M10, M12 | hft_ring |
telemetry | --for telemetry | M05, M07, M08, M10 | robotics_ring |
dev | --for dev | M04 | hft_ring |
Modules
A module is a crate of its own or a module inside one layer of a ring (crate::module). The ring column names the ring whose layer holds it, and is blank for a crate of its own.
| Module | Crate or module | Ring | What it does | Developer profiles |
|---|---|---|---|---|
| M01 | hft_stages::numa | hft_ring | Hugepages + NUMA-aware ring placement | hft, ai |
| M02 | dev_stages::conflation | dev_ring | Symbol-keyed last-write-wins router for quote-style traffic | hft |
| M03 | hft_functions::wait | hft_ring | UMWAIT / Park / BusySpin + smart poll_hint | hft, robotics |
| M04 | hft_stages::spmc | hft_ring | Lock-free SPMC ring (1 publisher, N readers) | hft, ai, robotics, dev |
| M05 | robotics_stages::net_uring | robotics_ring | io_uring batched send/recv for cross-host fan-out | ai, telemetry |
| M06 | robotics_stages::large_payload | robotics_ring | Tier-2 envelope LRU + NACK backfill | ai |
| M07 | robotics_stages::autoscale | robotics_ring | LagObserver + swap-ring protocol | telemetry, ai |
| M08 | pms-telemetry-export | Prometheus exporter | telemetry, robotics | |
| M09 | hft_core::prefetch | hft_ring | Adaptive cache-line prefetch | hft |
| M10 | pms-crc32c | CRC32C trailer integrity | robotics, telemetry | |
| M11 | hft_core::wire_v2 | hft_ring | Wire format v2 (separate data-ready line) | hft |
| M12 | mesh-broadcast | In-process listener fan-out | ai, robotics |
Crate index
Reference · generated · every workspace crate by tier, with the description its manifest carries
A tier depends only on the tiers below it; the peripheral surface depends on all of them and is depended on by none. The description column is each crate’s Cargo.toml description, so this table is exactly what cargo metadata reports. The status column is the crate’s package.metadata.pulse-mesh.status: pre-integration for a crate the tree ships but does not use yet (ADR-0014), partial for one in use some of whose public entry points return a deferred error (ADR-0017), blank for one that is finished and wired in.
The fabric (tier 2, mesh/)
| Crate | Path | Status | Description |
|---|---|---|---|
mesh-basket | mesh/mesh-basket | Pulse Mesh envelope basket: the daemon’s SQLite capture of published envelopes, which pms-query and the MCP server’s tools query by channel, time, type and origin. Tier 2 (the fabric). | |
mesh-broadcast | mesh/mesh-broadcast | Pulse Mesh BroadcastRegistry + ListenFilter: in-process fan-out of PulseEnvelopes to matching listeners. Tier 2 (the fabric). | |
mesh-client | mesh/mesh-client | Pulse Mesh — the Rust client for the daemon: Mesh, a synchronous handle over the Universal Port Protocol, with endpoint discovery, bounded queues, timed calls and callbacks on a thread of its own. Tier 2 (the fabric). | |
mesh-health | mesh/mesh-health | Pulse Mesh HealthRegistry: per-node heartbeat tracking that escalates a silent node through Healthy → Warning → Restarting → Isolated and reports drift as Degraded. Tier 2 (the fabric). | |
mesh-hub | mesh/mesh-hub | Pulse Mesh local daemon — terminates the universal port protocol over UDS + WebSocket. | |
mesh-hub-runtime | mesh/mesh-hub-runtime | Pulse Mesh embedded hub runtime — the event-bus library that mesh-hub (the daemon) wraps. | |
mesh-link | mesh/mesh-link | Pulse Mesh links between daemons, without a transport: qualified origins, interest summaries and their pacing, the forwarding decision, the forward cache, the hop limit and the link’s control messages (ADR-0021). Tier 2 (the fabric). | |
mesh-module-hub | mesh/mesh-module-hub | pre-integration | Pulse Mesh child-hub abstraction — bridges a node’s internal processing to a parent PulseHub. |
mesh-port-protocol | mesh/mesh-port-protocol | Pulse Mesh Universal Port Protocol v0 — the wire types, the payload base64 codec and the Unix-socket framing shared by the daemon and its Rust clients. Tier 2 (the fabric). | |
mesh-schema | mesh/mesh-schema | Pulse Mesh schema registry — envelope-type ACLs and producer/consumer relationships. |
The dev ring (tier 3, dev_ring/, dev_ring/)
| Crate | Path | Status | Description |
|---|---|---|---|
dev-core | dev_ring/dev-core | Pulse Mesh — dev_ring core layer: shared types for the ring’s functions and stages. Empty by design; kept so the four-crate shape is uniform. | |
dev-functions | dev_ring/dev-functions | Pulse Mesh — dev_ring functions layer: the basket re-export and the core_ops the pms command line and the MCP server share. | |
dev-hub | dev_ring/dev-hub | Pulse Mesh — dev_ring hub layer: the ring’s import point; re-exports core, functions and stages, emits the ring manifest via mesh_stage_registry::register_stages!, and re-exports the Rust client (mesh-client) that lived here before ADR-0012. | |
dev-stages | dev_ring/dev-stages | Pulse Mesh — dev_ring stages layer: the symbol-keyed conflation router over a PulseRing. |
The peripheral surface (tier peripheral, tools/, examples/)
| Crate | Path | Status | Description |
|---|---|---|---|
multi-process | examples/multi-process | Pulse Mesh template — two Rust processes (sender + receiver) over the local mesh-hub daemon. | |
pms-cli | tools/pms | pms — unified developer CLI for the Pulse Mesh Standard. | |
pms-mcp | tools/pms-mcp | Model Context Protocol server for the Pulse Mesh Standard. Exposes mesh introspection + the cross-language canonical codec proof to LLM agents. | |
pms-telemetry-export | tools/pms-telemetry-export | Pulse Mesh metrics exporter: Prometheus output for processes running the mesh. |
Error catalogue
Reference · generated · every public error type in the workspace, variant by variant, with the meaning its documentation states
The table is read from the source, so a variant that exists is listed and a variant that is gone disappears. The troubleshooting guide gives remedies for the errors a user meets from the command line.
ConflationError in dev_ring/dev-stages
Defined in dev_ring/dev-stages/src/conflation/mod.rs.
| Variant | Meaning |
|---|---|
Saturated | More keys than ring slots. Nothing in this module raises it; a [KeyExtractor] that enforces the bound may. |
PayloadTooShort | Payload is shorter than the configured key-extraction range. |
PayloadTooLarge | The payload is longer than [MAX_LOGICAL_PAYLOAD]. |
Source | The [Source] behind a drainer has failed for good; its own error is carried as the cause. |
BasketError in mesh/mesh-basket
Defined in mesh/mesh-basket/src/lib.rs.
| Variant | Meaning |
|---|---|
Sqlite | The SQLite engine failed. |
Io | Creating the basket file’s directory failed. |
InvalidQuery | The [QuerySpec] is invalid: a time-bucket aggregate of zero seconds. |
Poisoned | A thread panicked holding the connection; surface the error rather than retry on this Basket. |
DevError in mesh/mesh-client
Defined in mesh/mesh-client/src/error.rs.
| Variant | Meaning |
|---|---|
NoDaemon | PMS_DAEMON names neither a unix:// path nor a ws:// or wss:// URL. |
ForeignDaemon | The socket at the default path, /tmp/pms.sock, is served by another user. Anyone may create that path before the daemon does, and a client connected to it would hand that user everything it sends, so the client does not use it. |
VersionMismatch | The protocol version the daemon speaks is not supported by this client. |
Transport | The client could not connect, or start its runtime or threads. |
Tls | The TLS handshake of a wss:// endpoint failed: the daemon’s certificate chains to none of the roots the client trusts (webpki-roots’ and those given with MeshOptions::with_tls_root_pem) or does not name the host, the daemon does not speak TLS, or a root given is not a PEM certificate. Nothing was sent. |
Protocol | A request could not be serialized, or the WebSocket handshake did not accept the port subprotocol. |
Daemon | The daemon returned an error op: in reply to a request, or unprompted just before it closed the connection (limit_exceeded at its connection cap, payload_too_large after a frame over its limit), which every call still waiting and Mesh::wait then report. Every code but auth_failed, which is [DevError::AuthFailed]. |
AuthFailed | The daemon refused the attach with auth_failed and closed the connection: it allows only the keys it lists and this key is not one, or it requires a key (MeshOptions::with_identity) and the attach carried none, or the proof did not verify, or it came too late. Mesh::wait and every call still waiting report it too. |
KeyFile | A key file could not be made, read or used (MeshOptions::with_identity_file, identity): it cannot be opened, its group or others may read or write it, or it holds no key. path is empty when no random bytes could be had for a new key. |
NotAttached | Called before attach succeeded. |
DisconnectedDuringRpc | The call got no answer: the connection had already ended when it was made, or ended without answering it. |
Timeout | The daemon did not answer within MeshOptions::rpc_timeout, and the call was cancelled (see Mesh::late_replies). |
QueueFull | The call was refused, having sent nothing, because the client’s queue toward the daemon stayed full until its deadline (see client::COMMAND_QUEUE): the daemon reads more slowly than calls are made, or has stopped. The call may be retried. |
ReentrantCall | A blocking call was made on a thread in an async runtime’s context (a task’s, or a spawn_blocking or block_in_place closure’s), where blocking would deadlock or panic. Call from a plain std::thread, or from a subscription callback, which runs on one. |
Disconnected | The connection ended, not by a detach the daemon acknowledged and with no error from the daemon: it closed the connection, or a write failed. Mesh::wait and every call still waiting report it. |
Silent | The daemon sent nothing for MeshOptions::silence_limit while the client waited for it, and the client closed the connection; Mesh::wait and every call still waiting report it. A stopped daemon or host looks like this. |
MeshDropped | A WeakMesh was upgraded after every Mesh of its connection had been dropped, which closed the connection. Nothing was sent. |
AttachKeysError in mesh/mesh-hub
Defined in mesh/mesh-hub/src/attach_keys.rs.
| Variant | Meaning |
|---|---|
Read | The file could not be read, or is not UTF-8. |
Line | A line is neither blank, a comment, nor a key. |
DaemonError in mesh/mesh-hub
Defined in mesh/mesh-hub/src/daemon.rs.
| Variant | Meaning |
|---|---|
Signals | A signal handler could not be installed; signal names it. |
Uds | The Unix socket could not be claimed or served: another daemon holds the path, or the socket could not be bound. |
Ws | The WebSocket address could not be bound or served. |
Metrics | The metrics address could not be bound or served. |
Hub | The hub could not be created; the string is the bridge’s reason. |
Basket | The basket file could not be opened. |
StoreRefused | The envelope store asked for could not be made (the runtime logged why), and the daemon will not run without it: attached processes would receive nothing. |
Links | The links to other daemons could not be started: their configuration was refused, their address could not be bound, or a thread could not start. |
EmbedError in mesh/mesh-hub
Defined in mesh/mesh-hub/src/embed.rs.
| Variant | Meaning |
|---|---|
Runtime | The async runtime could not be built, or the thread that watches the hub’s name for another owner could not be started. |
Hub | The hub itself could not be created; the string is the bridge’s reason. |
Bind | The listener could not bind, with the address it was asked for. |
Nowhere | No bind was given, so there would have been nowhere to answer. |
Links | The links to other daemons could not be started: their configuration was refused, their address could not be bound, or a thread could not start. |
BridgeError in mesh/mesh-hub
Defined in mesh/mesh-hub/src/hub_bridge.rs.
| Variant | Meaning |
|---|---|
Init | The hub could not be created; the message is the runtime’s reason (see [HubBridge::new]). |
Dispatch | The hub’s schema registry refused a send’s envelope; the message is the underlying error. |
TooLong | A send’s channel or type name longer than [MAX_CHANNEL_LEN], or a filter string longer than [MAX_PATTERN_LEN]; nothing was published or subscribed, and the daemon answers bad_request. |
UnknownDestination | A send’s destination is no node attached here, and none a linked peer announced; nothing was delivered, and the daemon answers unknown_destination. |
AnnounceError in mesh/mesh-hub
Defined in mesh/mesh-hub/src/link/announce.rs.
| Variant | Meaning |
|---|---|
Length | The bytes are not the 47 of an announcement. |
Magic | The bytes do not start with [ANNOUNCE_MAGIC]. |
Version | A version this build does not read. |
Port | The announced port is zero. |
DaemonId | The daemon id is not the fingerprint of the announced key. |
LinkPeerError in mesh/mesh-hub
Defined in mesh/mesh-hub/src/link/mod.rs.
| Variant | Meaning |
|---|---|
NoKey | The text has no = between an address and a key. |
Address | What comes before the = is not an IP address and port. |
Key | What comes after the = is not 64 hex digits. |
LinkSetupError in mesh/mesh-hub
Defined in mesh/mesh-hub/src/link/mod.rs.
| Variant | Meaning |
|---|---|
NoPeers | The configuration names no peer. |
OwnKey | A peer has this daemon’s own key. |
OwnPeerKey | A peer named by its key alone has this daemon’s own key. |
PeerKeysWithoutAnnounce | Peers are named by their key alone, and nothing announces where they are. |
AnnounceGroup | The announcement group is not an IPv4 multicast address, or its port is zero. |
AnnounceTtl | The announcements’ TTL is not 1 to 255. |
AnnounceBind | LAN announcement is asked for links bound to an IPv6 address, which an IPv4 announcement cannot give. |
Announce | The announcement group could not be joined, or its sender set up. |
DuplicatePeer | Two peers have one key, or keys with one daemon id. |
TrustedRelayNotPeer | A key trusted as a relay is not a configured peer’s. |
DuplicateAddress | Two peers have one address. |
Timing | The heartbeat interval is zero, or not shorter than the silence limit, so a link that works would be taken down. |
Bind | The link address could not be bound, or its socket set up. |
Thread | A link thread could not be started. |
DatagramError in mesh/mesh-hub
Defined in mesh/mesh-hub/src/link/wire.rs.
| Variant | Meaning |
|---|---|
Envelope | The frame’s bytes are not a canonical envelope with its own id in the form a link writes: type 0, a zero origin and time, no flags and a raw payload. |
Pulse | The frame’s Pulse does not name the envelope it carries, or names another daemon than the peer it came from. |
Kind | The envelope’s type name is neither link type. |
Control | A control message that does not decode. |
Forward | A forward whose layout is not this build’s. |
Hello | A Hello that is not a link Hello. |
TooLarge | What would be sent is longer than a run of fragments carries. |
WsTlsError in mesh/mesh-hub
Defined in mesh/mesh-hub/src/tls.rs.
| Variant | Meaning |
|---|---|
Read | A PEM file could not be read. |
Certificates | The certificate chain holds no certificate, or is not PEM. |
Key | The private key’s PEM holds no key, or is not PEM. |
Refused | rustls refused the certificate and key: the key is not one it can sign with, or not the certificate’s. |
HubError in mesh/mesh-hub-runtime
Defined in mesh/mesh-hub-runtime/src/lib.rs.
| Variant | Meaning |
|---|---|
TemporalViolation | A published pulse was not later than the last one accepted, the SIG-T05 invariant: clock skew or a replay. Nothing was written. |
Schema | The schema registry refused the envelope. |
ReadOnly | This hub did not create its ring, whose one writer is its creator; nothing was written. Publish by sending to the daemon instead. |
OwnerError in mesh/mesh-hub-runtime
Defined in mesh/mesh-hub-runtime/src/lib.rs.
| Variant | Meaning |
|---|---|
Owned | A live hub owns the name; nothing was removed or created. |
Lock | The name’s lock file could not be taken for a reason other than a live owner (see [LockError]); nothing was removed or created. |
Ring | The hub name is invalid, or the hub’s ring could not be removed or created. |
LockError in mesh/mesh-hub-runtime
Defined in mesh/mesh-hub-runtime/src/owner.rs.
| Variant | Meaning |
|---|---|
Held | A live process holds the lock: the hub name or the socket path has an owner, and nothing it guards was touched. |
Io | The lock file could not be opened, locked or checked: the operating system’s error, such as EACCES for a directory this user cannot write. |
NotPlain | The lock path names something other than a plain file with one name, such as a symbolic link or a hard-linked file, which could redirect the holder’s writes. |
Displaced | The lock file was removed while its holder lived, and its ownership check ([OwnerLock::set_ownership_check]) says another owner has taken what it guards: the holder is displaced, and leaves the path to the next owner. |
CodecError in mesh/mesh-link
Defined in mesh/mesh-link/src/codec.rs.
| Variant | Meaning |
|---|---|
TooLarge | A message, or a field in one, is longer than its cap. |
Truncated | The bytes end inside the field that starts at at, or before the entries a count there declares. |
TrailingBytes | Bytes follow the end of the message. |
UnknownVersion | The encoding’s version is not one this build reads. |
UnknownKind | A control message’s kind is not one this build knows. |
UnknownFlags | An interest summary sets flag bits this build does not know. |
UnknownState | A health notice’s state code is not one this build knows. |
BadPart | An announcement’s part is not below its count of parts, or the count is 0 or above 64 (MAX_ANNOUNCE_PARTS). |
NotUtf8 | A text field that starts at at is not UTF-8. |
NotCanonical | The bytes are well formed but not in the one form the encoding allows for their value, such as a set out of order; the reason says which rule they break. |
LinkError in mesh/mesh-link
Defined in mesh/mesh-link/src/link.rs.
| Variant | Meaning |
|---|---|
Closed | The link is closed, here or at the peer. |
Encode | A control message could not be encoded, as one past its cap cannot. |
Decode | The peer sent bytes that are not a control message. |
EnvelopeTooLarge | The envelope is longer than the link carries. |
OriginError in mesh/mesh-link
Defined in mesh/mesh-link/src/origin.rs.
| Variant | Meaning |
|---|---|
NameTooLong | The name is longer than a daemon admits at attach. |
NoDaemon | The text has no @ between a name and a daemon. |
BadDaemon | The daemon is not 16 lowercase hex digits. |
RouteError in mesh/mesh-link
Defined in mesh/mesh-link/src/router.rs.
| Variant | Meaning |
|---|---|
UnknownPeer | The daemon is not linked to this peer. |
LocalDaemon | A daemon is not its own peer. |
ForeignNode | A peer announced as its own a node another daemon qualifies. |
ViaNode | A peer announced as reached through it a node of its own, or of this daemon. |
BadPart | A part of an announcement outside its parts, or whose count of parts is not its generation’s. |
ProofError in mesh/mesh-port-protocol
Defined in mesh/mesh-port-protocol/src/attach.rs.
| Variant | Meaning |
|---|---|
Text | The field’s text is not the padded standard base64 of as many bytes as the field holds. |
Key | The public key is not an Ed25519 point, or is one of small order. |
Signature | The signature is not the key’s over the challenge, the name and the connection, or is not in its canonical form. |
TextError in mesh/mesh-port-protocol
Defined in mesh/mesh-port-protocol/src/attach.rs.
| Variant | Meaning |
|---|---|
Base64 | Not padded standard base64. |
Length | Base64 of the wrong number of bytes. |
Base64Error in mesh/mesh-port-protocol
Defined in mesh/mesh-port-protocol/src/base64.rs.
| Variant | Meaning |
|---|---|
Length | The length is not a multiple of four. |
InvalidByte | A byte outside the standard alphabet, whitespace and the URL-safe - and _ included. |
Padding | A = other than the one or two that end the text. |
TrailingBits | The symbol before the padding sets bits that carry no data, which no encoder writes. |
FrameError in mesh/mesh-port-protocol
Defined in mesh/mesh-port-protocol/src/framing.rs.
| Variant | Meaning |
|---|---|
Oversized | A frame longer than the decoder’s maximum, or a payload longer than a prefix can declare. |
ErrorCode in mesh/mesh-port-protocol
Defined in mesh/mesh-port-protocol/src/lib.rs.
| Variant | Meaning |
|---|---|
VersionUnsupported | The client announced a protocol version this daemon does not speak. |
NotAttached | The operation needs an attached connection. |
NameInUse | Another attached connection already holds that name. |
BadFilter | The subscribe filter could not be understood. |
UnknownSubscription | The subscription id is unknown or belongs to another connection. |
PayloadTooLarge | The payload exceeds what the daemon accepts. |
LimitExceeded | A limit of the daemon’s connection policy was reached; the message names the limit and the flag that sets it. A Unix-socket connection past the connection cap gets it unprompted, without an id, and is closed. |
BadRequest | The message is not a valid request: malformed JSON, a field missing or malformed, or a string longer than its cap. |
Internal | The daemon failed internally; the message carries detail. |
AuthFailed | A key-proof attach failed ([attach]): the proof did not verify, the key is not one the daemon allows, a prove came with no challenge pending or another request while one was, the challenge went unanswered past the daemon’s handshake timeout, or the daemon requires a key and the attach carried none. The daemon closes the connection after it. |
UnknownDestination | A send’s to names no node this daemon can deliver to: none of its own attached clients, and none a linked peer daemon announced. The message names the node; nothing was delivered. |
SchemaError in mesh/mesh-schema
Defined in mesh/mesh-schema/src/lib.rs.
| Variant | Meaning |
|---|---|
TypeIdExists | A schema with this type_id is already registered. |
NameExists | A schema with this name is already registered. |
UnknownType | No schema is registered under this type_id. |
ProducerNotAllowed | The producing node is not on the schema’s producer ACL. |
ValidationFailed | The payload failed the schema’s [ValidationMode]; carries the reason. |
TelemetryExportError in tools/pms-telemetry-export
Defined in tools/pms-telemetry-export/src/lib.rs.
| Variant | Meaning |
|---|---|
Encode | Failed to render the metrics buffer into Prometheus text format. |
Register | One of the canonical metrics failed to register against the Prometheus Registry — typically a duplicate-name collision when multiple Metrics instances share a registry. |
API Reference
Reference · where each API surface is documented and how to generate it
This book is a guided path; the crate-level API reference is generated straight from source, not duplicated into book chapters where it would drift. The one hand-kept exception is the daemon client’s surface on the Rust page, small enough to keep true by reading.
- Rust: the workspace’s own crates via rustdoc, and the client’s surface (
Mesh: attach, send, on, tap, run) by hand. - The codec bindings for Python, Node and C, and the WebAssembly codec, are the standard’s and are documented in its book; the thin clients that speak to this daemon from Python and Node are run in Two programs talk through the daemon.
Every page below generates its reference locally from the code actually in this repository.
Why generate instead of write
Rustdoc already carries the authoritative signatures, types, and per-item documentation the workspace’s missing_docs lint enforces (see Developer Onboarding). A hand-written API reference in this book would either duplicate that content and drift out of sync, or omit detail the generated reference already has for free. Generating it is also how the gate keeps the pages honest: a generated page that differs from its generator fails the docs check.
Rust
Reference · the `Mesh` client's surface, and where the rest of the Rust API lives
The Mesh client
Mesh, in the mesh-client crate (and re-exported by dev-hub, its home before ADR-0012, so dev_hub::Mesh keeps working), is the Rust client for the daemon: a synchronous handle over the Universal Port Protocol. Every method blocks the calling thread and returns a Result whose error is DevError. None may be called from inside an async runtime, where it would deadlock; such a call returns DevError::ReentrantCall instead.
| Call | What it does |
|---|---|
Mesh::attach(name) | Connects and registers the process under name. Tries the endpoint in PMS_DAEMON, which must name a unix:// path or a ws:// URL (anything else is NoDaemon), then /tmp/pms.sock, then ws://127.0.0.1:9876. A socket at /tmp/pms.sock that another user serves is refused with ForeignDaemon, since anyone may create that path first; a socket named in PMS_DAEMON is not checked. A daemon that refuses the attach answers with its code and message, name_in_use or, at its connection cap, limit_exceeded, which it says unprompted before it closes. |
Mesh::attach_with(name, options) | The same with a MeshOptions: with_endpoint(Endpoint::Uds(path)) or with_endpoint(Endpoint::Ws(url)), with_rpc_timeout(duration) (five seconds by default), with_heartbeat_interval(duration) (thirty seconds; zero switches the idle heartbeat off), with_event_queue(depth) (how many events may wait for the callbacks, 1,024 by default; a full queue drops the newest event and counts it). |
send(channel, payload) -> String | Publishes the bytes on the channel and returns the envelope identifier the daemon computed, as 32 hexadecimal digits. |
send_typed(channel, Some(type_name), payload) -> String | The same with a type name, which is part of the content and so of the identifier. |
send_with_receipt(channel, type_name, payload) -> SendReceipt | Publishes, with or without a type name, and returns the daemon’s receipt: envelope_id, and persisted, which is Some(true) when the daemon’s envelope store kept the envelope for readers of the hub’s shared memory, Some(false) when the store refused it (larger than a store entry, or no slot free) and None when the daemon runs without a store. A refused store does not fail the send, and the subscribers still receive the envelope. send and send_typed delegate to it and return only the identifier. |
on(channel, callback) -> SubscriptionId | Subscribes to one channel. The callback receives a MeshEvent per envelope, on a dispatch thread of the client’s own, so it may call back into Mesh. |
tap(filter, callback) -> SubscriptionId | Subscribes with a TapFilter: any of channel (exact or glob), type_id, type_name and origin; an empty filter matches everything. |
unsubscribe(id) -> u64 | Ends a subscription and returns how many of its events the daemon dropped because this connection’s queue was full (the daemon’s --subscriber-queue). |
events_dropped() -> u64 | How many events this client dropped because its own event queue was full, across every subscription since attach: events the daemon delivered and no callback ran. Replies are never dropped. |
events_malformed() -> u64 | How many events this client dropped because their payload was not valid base64, across every subscription since attach. A conforming daemon sends none; such an event never reaches a callback, so no callback receives bytes nobody published, and it is logged at the first and at each doubling. Not counted in events_dropped. |
run() -> Result<(), DevError> | Blocks until the connection ends and returns what wait reports, so a lost daemon cannot pass for a clean stop; pms attach and pms tap print that result and exit with status 2. |
wait() -> Result<(), DevError> | The same, returning Ok only after a detach the daemon acknowledged. Any other end is an error: the daemon’s parting error as DevError::Daemon when it gave one (limit_exceeded at its connection cap, payload_too_large after a frame over its limit; an error that answers no request is kept while it is the daemon’s last word), otherwise DevError::Disconnected, whether the daemon closed the connection or a write to it failed. Every call still waiting gets the same error, and a call made after the end gets DisconnectedDuringRpc. |
detach() | Leaves the mesh and releases the name. Returns Disconnected, or the daemon’s error, when the connection ends before the daemon acknowledges, which is then not a clean end. |
A MeshEvent carries subscription_id, envelope_id, channel, type_id, type_name, origin (the publisher’s node, as short@hex), captured_at_unix_nanos and payload.
DevError has one variant per failure: NoDaemon, VersionMismatch, Transport, Protocol, Daemon { code, message } (the daemon refused a request, or said why it was closing, and code is one of the protocol’s error codes as text; mesh_port_protocol::ErrorCode::parse gives the typed code, or None for one this build does not know), NotAttached, DisconnectedDuringRpc (the call got no answer because the connection had already ended when it was made, or its task stopped without answering), Timeout { op, after }, ReentrantCall and Disconnected (the connection ended other than by an acknowledged detach and without a reason from the daemon). The enum is non_exhaustive, as every error enum and configuration struct of the mesh tier is: a match on it carries a wildcard arm, since a later release can add a variant, and a configuration is built from its Default with with_* methods rather than a struct literal. The error catalogue lists them with their messages.
The rest of the API
Generate the reference for every workspace crate — pulse-canon, core-ring, mesh-hub-runtime, each ring’s *-hub crate, and so on — straight from source:
cargo doc --workspace --no-deps --open
This includes the module-level //! overview and every public item’s /// doc comment that CONTRIBUTING.md’s missing_docs lint enforces workspace-wide (see Developer Onboarding).
The three native language bindings (Python, Node, C) are Rust crates too, but sit outside the root workspace, in the standard’s repository (see Language bindings), so document them from their own directories:
cargo doc -p pulse-wire-udp --no-deps # the transport the bindings wrap
Glossary
Reference · for every reader; the book, the website, the command line and the error messages use these words in these senses
Terms are listed alphabetically. Where a word has more than one meaning in this tree, each meaning has its own entry and the book always says which one it means. The Sovereignty stack’s names are included because Pulse Mesh sits beside them; where a component is not part of this repository the entry says so.
ACK — A 12-byte feedback datagram (magic plus a u64 cursor) the UDP receiver sends to the sender to report its cursor, one past the highest sequence it has seen. The sender’s backpressure stall is released by it. Defined in pulse-wire-udp.
Aegis™ — The bounded, typed execution layer of the Sovereignty stack (Aegis / Construct). Not part of this repository.
Attach — The first operation on a port-protocol connection: the client names itself, announces the protocol version and the capabilities it wants, and receives its node id.
Backpressure — The feedback loop that stops a UDP sender from running more than target_queue_depth frames ahead of the receiver’s ACK cursor, for at most stall_timeout before the send fails. Amber in every figure.
Basket — The SQLite capture store of envelopes that the daemon writes for structured queries (mesh-basket; pms and the MCP server query it). Distinct from the envelope store, which is shared memory.
BLAKE3-16 — The first 16 bytes of a BLAKE3 hash computed under a domain tag. The envelope id is one.
Busy claim — A store writer’s claim that found its slot held by another writer, mid-store or crashed, or already taken by a newer lap, and gave up its cursor for a fresh one. Counted in the store’s header and reported by busy_claims(); readers skip the cursors given up.
Canon™ — The canonical codec, pulse-canon: the crate that turns a Pulse and a PulseEnvelope into bytes and back, exactly the same way in every implementation. Also the Sovereignty stack’s name for that semantic layer.
Capability handshake — The 48-byte Hello exchange in mesh-capability by which two nodes on plain UDP agree on a transport upgrade (NACK retransmission, FEC, and the higher-tier backends) from the intersection of their capability bits.
Channel — The routing key an envelope is published on through the daemon (pms push --channel, Mesh::send). Subscriptions filter on it, by exact name or glob.
Content id — See envelope id.
CRC trailer — A CRC32C over bytes 8 to 124 of a Pulse, stored in bytes 124 to 128 of its slot by publish_with_crc and checked by read_slot_verify_crc. pms-crc32c owns the computation.
Cursor — The ring’s monotonic write counter. The slot a write lands in is the cursor modulo the capacity.
Daemon — mesh-hub: the local process that terminates the port protocol over a Unix socket and a WebSocket and bridges every connection to the runtime hub. One of the three meanings of hub.
Detach — The port-protocol operation that releases a client’s name and closes its subscriptions cleanly.
Developer profile — A tag, one of hft, ai, robotics, telemetry and dev, that pms modules ls --for and pms doctor --for take to select optimization modules. It installs and enables nothing. Not the profiles repository. Each profile’s modules are listed in Modules and developer profiles.
Domain tag — A fixed string mixed into a hash so that identifiers from different contexts can never collide. The envelope id uses pulse-envelope-id/v2.
Envelope — See PulseEnvelope.
Envelope id — The 16-byte BLAKE3 hash of an envelope’s canonical bytes under its domain tag. Identical content produces the identical id in Rust, Python, Node, C and WebAssembly; verify() recomputes it.
Envelope reference — The sixteen-byte envelope_ref field of a Pulse: the content id of the PulseEnvelope it names, byte for byte, or all zeros when the Pulse carries none. It has been part of the Pulse since the first commit and chapter 2 of the specification defines it; Pulse::envelope_id() reads it as a PulseEnvelopeId and set_envelope_id writes it. The daemon’s runtime fills it on the signal Pulse it writes for each stored envelope, and a second process attached to the hub fetches the envelope by it.
Envelope store — The content-addressed shared-memory store that sits beside a ring (core-store), where a Pulse’s envelope_ref resolves to the envelope it names. Layout v2 carries a magic, a version, a claim counter and per-slot sequence words, and holds each envelope as its canonical bytes, verified when read. Emerald in every figure.
FEC — Forward error correction in pulse-wire-udp: parity datagrams over a window of frames, so a receiver can rebuild a lost frame without asking for it.
Fixture — A byte-exact test vector under pulse/pulse-spec/test-vectors. Every implementation of the codec must reproduce the fixtures byte for byte; the conformance pages and the website’s scrubber are built on them.
Four-crate ring — The shape every ring under rings/ has: <ring>-core (shared types), <ring>-functions (stateless primitives), <ring>-stages (stateful processing) and <ring>-hub (the composed surface a consumer depends on). A layer with nothing to hold stays as an empty crate so the shape is uniform.
Four-question test — The rule in CONTRIBUTING.md for where new code goes: whether it touches the canonical Pulse atom, touches the canonical envelope, crosses a ring boundary or sits on the hot path decides whether it belongs in the inner stack; anything else is peripheral.
Heartbeat overlay — The HeartbeatPayload that occupies the last 32 bytes of a Pulse whose instruction is Heartbeat, read and written through heartbeat_payload and set_heartbeat_payload.
Hermes — The orchestration surface of Sov OS. Not part of this repository.
Hub — Three things, and the book says which: the daemon (mesh-hub), the runtime event bus it wraps (PulseHub in mesh-hub-runtime, combining a ring, an envelope store, the schema and broadcast registries and the health registry), and a ring’s hub crate (<ring>-hub).
Instance identifier — Sixteen random bytes a hub draws when it is created or opened, so every handle on a hub name has its own. The hub writes it into an origin_id left unset on the Pulses it publishes and skips the envelope signals that carry it, since its own listeners already had those envelopes.
Instruction — The 16-bit code in a Pulse that says what it signals: Null, Heartbeat or Data in this tree.
Knox™ — Cryptographic assertion in the Sovereignty stack (Knox / Musashi). Not part of this repository.
Kronos™ — The Sovereignty stack’s temporal authority: time as a load-bearing coordinate. The monotonic timestamp every Pulse carries and the hub’s temporal validation mode are the parts of that discipline present here.
Lane — One ring of a MultiLane set (hft-stages), named <prefix>-l<index>, so several writers can run in parallel without sharing a slot.
Lap — One pass of a ring’s or a store’s write cursor around its slots: cursor / capacity. The store’s sequence words encode the lap (2·lap + 1 in progress, 2·lap + 2 published), so a reader can tell that a slot holds the cursor it asked for and not the same slot from an earlier pass. The ring’s slot counter serves a poller the same way: each completed write adds two, so a copy whose counter shows more writes than its position’s lap needs may hold the next lap and is counted lost.
Layout version — The number a shared-memory header carries for its own layout: PMES version 2 for the envelope store, PMSR version 1 for the ring header. An open refuses a region whose version is not its own (LayoutMismatch). The ring’s header version numbers the header, not the slot format, which is version 1 at 128 bytes or version 2 at 192.
Lexicon™ — The conformance registry and the Merkle root over its public entries (mesh-lex), historically written R-LEX. The lex root anchors the published catalog; the daemon logs it at start.
Mesh — The set of processes attached to one hub, and by extension the substrate they share.
Monotonic nanoseconds — The monotonic_nanos field of a Pulse: a clock reading that must not go backwards within a hub, which the temporal validation mode enforces on the Pulses an application publishes. A hub stamps its own signals with the host’s monotonic clock (CLOCK_MONOTONIC on Linux, CLOCK_UPTIME_RAW on macOS) unless the application drives its clock.
MultiLane — N rings striped for parallel writers in the HFT ring, with NUMA placement and per-lane writer pinning.
Musashi™ — Proof-gated execution in the Sovereignty stack: authorization as witnessed mathematics. Not part of this repository.
NACK — A 12-byte feedback datagram asking the sender to retransmit one sequence number from its send cache. The receiver stages each missing sequence once, emits at most sixteen per cycle, repeats a request after four cycles and gives up after three; the sender bounds its queue and its retransmits per send.
Node id — The 16-byte identity the daemon derives from a client’s attach name (NodeId::from_name), rendered as name@hex.
Optimization module — One of twelve capabilities, M01 to M12, each a crate of its own or a module inside one layer of a ring, tagged with the developer profiles it suits. pms modules ls lists them, from a static table in the dev ring.
Origin — The producer’s node id as carried on an envelope event and matched by subscription filters.
Owl™ — Observation and telemetry in the Sovereignty stack. Not part of this repository.
Port protocol — See Universal Port Protocol.
Profiles (repository) — pulse-mesh-profiles, the repository of the enterprise, HFT and robotics rings, composed on the standard. Not a developer profile.
Pulse — The 128-byte atom: eleven fields in two cache lines, the first 8 bytes being the SeqLock word of the slot it sits in. Cyan in every figure.
Pulse Mesh™ — The wire format and the shared-memory transport this book documents, and the substrate the Sovereignty stack moves through.
PulseEnvelope™ — The variable-size, content-addressed envelope: a header, a payload that is raw bytes or a nested list of envelopes, and the envelope id computed over the canonical bytes. Violet in every figure.
PulseRegion — A shared-memory region holding exactly one Pulse under the SeqLock protocol (core-region).
PulseRing — The SeqLock ring in core-ring: a header and N slots in POSIX shared memory, one writer, whose publishes take its handle’s &mut, and any number of readers, each through a handle of its own in the writer’s process. The header records the ring’s magic, header version, capacity and slot size, and open refuses a mismatch. Version 1 slots are 128 bytes; version 2 slots are 192 bytes and add a ready line.
Ready line — The 8-byte word at bytes 128 to 136 of a version-2 slot that the writer bumps after a publish, so a hardware-wait receiver can monitor it instead of the SeqLock word. A reader may also poll it in place of the counter (PulseRing::read_position_by_ready).
Recovery window — The range of sequence numbers a UDP receiver may still ask the sender to retransmit: the sender’s send-cache capacity, 1,024 frames by default. A gap wider than the window keeps only its newest window of sequences staged; the rest are given up and counted.
Ring (buffer) — A PulseRing. When the book means the data structure it says ring buffer or PulseRing.
Ring (domain surface) — One of the four tier-3 surfaces: the dev ring, in the daemon repository, and the enterprise, HFT and robotics rings, in the profiles repository. When the book means these it says the dev ring, the HFT ring, and so on.
RITA™ — Modular role inference in the Sovereignty stack: governance mathematics as a replaceable part. Not part of this repository.
Sage™ — Deterministic governance in the Sovereignty stack. Not part of this repository.
Satellite — An external repository that implements a binding or an adapter against the conformance fixtures. modules.toml, in the profiles repository, defines the fields such a repository registers with; it lists none today.
SeqLock — The sequence-counter protocol that protects a slot: the writer marks the counter odd, copies the payload, and marks it even; a reader that sees an odd counter, or a counter that changed across its copy, retries for a bounded number of spins and then gives up.
Slot — One Pulse-sized cell of a ring, addressed by cursor modulo capacity.
Sov OS™ — The integrated experimental substrate of the Sovereignty stack, also written sovOS. The hub runtime and the module hub in this tree were vendored from its mesh core.
Stage — See four-crate ring.
Stall deadline — How long a UDP send may wait for the receiver’s cursor under backpressure before it fails with SenderError::Stalled: stall_timeout, five seconds by default, None for no deadline.
Subscription — A server-pushed stream of events matching a filter, opened by subscribe and identified by the id the ack carries. A tap is a subscription opened from the command line or Mesh::tap.
Thin client — The Python and Node clients in the daemon repository (clients/) that speak the port protocol directly, without the Rust client.
Tier — One of the four dependency layers: pulse (0), core (1), mesh (2), rings (3). A tier depends only on the tiers below it; the peripheral surface sits outside them and depends on all of them.
Type id, type name — The numeric and the named envelope type. A name-routed envelope carries its name, and its type id is 0 unless the sender gave one; a schema registered for a type id other than 0 checks it, named or not. Filters may match either.
Universal Port Protocol — Version 0 of the JSON protocol between the daemon and its clients: the operations attach, detach, send, subscribe, unsubscribe and health, and the replies ack, error, event and health, over a WebSocket (subprotocol pms-universal-port-v0) or a length-prefixed Unix socket. The types live in mesh-port-protocol.
WISP™ — The Sovereignty stack’s identity primitive. Not part of this repository; the registry at sovfound.org/registry records the mark and its status.
Why it is shaped this way
Why it is shaped this way
One claim, the mechanism that makes it true, and what would refute it.
Explanation · for anyone who wants to understand the design before trusting it
An explanation page makes one claim about the design, shows the mechanism that makes it true, and says what would refute it. It does not give steps and it does not list facts for lookup; those belong in the other parts. This opener carries the first explanation, the shape of the whole system, because every later page assumes it.
- Substrate versus contract: where the standard’s substrate ends and the daemon’s contract begins; the substrate itself is The SeqLock memory model, in the standard’s book, with Why a 128-byte atom and Why content addressing before it.
- Daemon design, Trust boundaries: moving bytes between processes; between machines is Transport design, in the standard’s book.
- Decision log: what was decided.
The shape of the system
The system is four tiers, and a tier depends only on the tiers below it. This repository holds tier 2 and one tier-3 ring; the bottom two tiers are the standard, and the other rings are the profiles.
| Tier | Where | What lives there | Color in every figure |
|---|---|---|---|
| 0 | pulse/, in the standard | The canonical primitive: the Pulse, the PulseEnvelope, the codec that turns them into bytes, the conformance artifacts that pin those bytes, and the vocabulary. | Cyan for the Pulse, violet for the envelope |
| 1 | core/, in the standard | The engine: the SeqLock ring, the envelope store, the single-slot region, the reference UDP transport, the transport trait, the backoff and CRC primitives, and the macro the rings compose with. | Emerald for the substrate, amber for transport |
| 2 | mesh/, here | The fabric: the daemon and its port protocol, the embedded hub runtime, the Rust client, broadcast, health, the envelope basket. | Emerald and amber, by what each crate touches |
| 3 | dev_ring/, here; rings/ in the profiles | Domain surfaces composed over the fabric: the dev ring here, and the enterprise, HFT and robotics rings in the profiles. | Slate for what a figure excludes |
Beside the tiers, each repository has its leaves: here the pms tools, the thin clients, the basket query tool, the examples and the fuzz harness; in the standard the pulse command line, the bindings, its examples and its fuzz harness; in the profiles the XDP plugin and the HFT telemetry tool. They consume the tiers and are consumed by nothing.
Every ring has the same four crates, and the shape is deliberate. <ring>-core holds the shared types the ring’s other layers reuse, with no logic and no I/O. <ring>-functions holds stateless primitives. <ring>-stages holds the stateful processing: pipelines, lanes, delivery. <ring>-hub composes the three and is the only crate a downstream consumer depends on. A layer that has nothing to hold stays as an empty crate, so that every ring reads the same way; dev-core is one. The macro that composes a hub, register_stages!, lives in the standard’s core/mesh-stage-registry, because it depends on nothing and every hub uses it.
The colors in the table are the website’s and the book’s: cyan is the Pulse, violet the envelope, emerald the substrate that holds them, amber the transport that moves them, rose an error or a mismatch, slate whatever a figure sets aside. A figure that uses them is self-describing once the legend is known.
What would refute this
The claim is that the tiers depend only downward and that every ring keeps the four-crate shape. cargo tree over each workspace is the check, and scripts/arch-check.py runs it in every repository’s gate: a tier-2 crate here that depends on the dev ring, a crate of the standard that depends on anything here, or a ring whose hub is not the only entry point, contradicts the page.
Substrate versus contract
Explanation · for anyone who has met a "maximum envelope size" somewhere in the tree and wants to know whether it is part of the format
The claim. The envelope contract has no size limit, and every limit a reader meets belongs to the substrate that is carrying the envelope at that moment. Keeping the two apart is what lets one wire format run over a datagram, a shared-memory ring and a daemon socket without three definitions of an envelope.
The mechanism
The canonical bytes of an envelope are a function of its fields and nothing else; the specification says so and the vectors prove it. A limit on how many of those bytes a particular medium can carry is a fact about the medium. Three such facts exist in this tree, each recorded where it applies:
| Limit | Medium | Where it is recorded |
|---|---|---|
| 1,315 bytes | One datagram of the reference UDP transport: a 1,472-byte path less the 153-byte frame header and the 4-byte CRC. | pulse-wire-udp, as MAX_ENVELOPE; a sender refuses a larger envelope at encode time unless fragmentation is switched on at both ends, when an envelope up to MAX_MESSAGE (66,560 bytes) travels as a run of datagrams and is reassembled; the limit moves, and stays the transport’s. |
| 65,536 bytes | One entry of the envelope store. | The store’s header, so a reader attaching to a store learns the deployment’s limit without being told. |
| The daemon’s frame cap | One message on a daemon connection. | The daemon’s connection policy. |
An implementation that carries envelopes over a medium with no such limit, shared memory between two processes for instance, MUST NOT impose one, and a transport that refuses an envelope its medium cannot hold is not violating the contract; it is reporting a property of itself.
The same separation runs through the rest of the design. The ring’s slot size is a substrate fact and the Pulse’s 128 bytes are a contract fact; they coincide by construction, and a version-2 slot that adds a ready line changes the substrate without touching the Pulse. The daemon’s protocol version and the standard’s draft revision are two counters because they change for two reasons.
Why it matters
Confusing the two produces the wrong fix in both directions. Raising an envelope limit “in the format” to suit one transport would force every other implementation to change for a property it does not have; lowering one to match the smallest medium would forbid what shared memory carries for free. Naming the limit after its medium keeps each change local.
What would refute this
A conformant decoder that rejects an envelope for its size alone, or a substrate limit that had to be written into the canonical bytes to be enforced, would refute the separation. The codec’s tests decode envelopes far larger than any substrate limit, and the transport’s tests refuse at the transport, not in the codec.
Spoken walkthrough
A script for the video and audio version of this page, about a minute.
Somewhere in the tree you meet a maximum envelope size. Is it part of the format? No. The format has no size limit; the medium carrying the envelope at that moment does.
A UDP datagram carries about thirteen hundred bytes of envelope, or about sixty-five kilobytes if you switch fragmentation on at both ends. A store entry carries sixty-four kilobytes, and says so in its header. A daemon connection carries what its policy allows. Shared memory between two processes carries whatever you like, and an implementation on a medium with no limit must not invent one.
Keeping the two apart is what lets one wire format run over a datagram, a ring and a socket without three definitions of an envelope. Raise a limit in the format to suit one transport, and every other implementation changes for a property it does not have. Name the limit after its medium, and each change stays local.
Daemon design
Explanation · for anyone running `mesh-hub` for other processes, and for anyone who wants to know what one misbehaving client can and cannot do to the rest
The claim. One misbehaving client of the daemon, whether silent, slow, greedy or duplicated, costs the other clients nothing: every resource a connection can consume is bounded by a policy the operator sets, the number of connections is bounded by the same policy, and every failure is reported to the client that caused it.
The mechanism
The daemon is a library with a binary in front of it: the crate’s run takes a DaemonConfig (the hub name and configuration, the socket and the address to serve, the connection policy, a basket) and serves until a signal or a displacement, and the binary only parses its flags into that configuration; a host that embeds the daemon calls the same surface without the signals. A connection is one task that owns its transport in both directions and a bounded queue of outbound messages. Four timers and four sizes govern it. A WebSocket client is pinged every thirty seconds and a Unix-socket client is expected to say something itself, so a connection silent for ninety seconds is closed rather than held open forever with a name nobody can reuse. A write that does not complete in ten seconds means the peer has stopped reading, and the connection is closed rather than letting its queue grow without bound. A WebSocket upgrade that does not finish in five seconds is dropped, so a connection that never speaks cannot park a task. A frame larger than a megabyte is refused with an error the client can act on, and the daemon reads a frame incrementally rather than allocating the announced length up front, so a four-byte header cannot buy sixteen megabytes. A connection holds at most sixty-four subscriptions, because every send is matched against every live subscription and the cap bounds what one connection adds to the cost of each; the subscribe past it is refused with limit_exceeded, the connection stays open, and an unsubscribe frees a place. The daemon holds at most 256 connections across both transports, counted from accept to close: past that, a Unix-socket client is turned away with one error frame and a WebSocket upgrade is answered 503, each refusal counted, logged at the first and at each doubling, and printed at exit.
Events are the one thing a client cannot be trusted to consume at the daemon’s pace, so they go through a bounded queue, 1,024 deep by default, that the publisher writes into without waiting. When the queue is full the newest event is dropped and counted; the count is logged at the first drop and at each doubling so a log does not fill either, and it is handed back to the subscriber in the acknowledgement of its unsubscribe, so the loss is the subscriber’s to see. Acknowledgements and errors bypass the queue and are never dropped. The alternative, disconnecting a slow subscriber, would turn a brief stall into a reconnect and a resubscribe that loses the same events anyway; it remains available as a policy if a deployment wants it.
A publish the envelope store refuses, because the envelope is larger than a store entry or every slot the store tried was held, is still delivered to every matching subscriber and still acknowledged. The acknowledgement says persisted: false, which the Rust client returns from send_with_receipt, the daemon counts the refusal, logs it at the first and at each doubling, prints the count when it exits, and writes no ring signal for an envelope that is not in the store, so a process reading the ring is never pointed at an envelope that is not there. Before this rule a refusal was neither counted nor reported, and the client was acknowledged as if the envelope had been stored. A store that shared memory has no room for is refused when the daemon starts, with the reason in its log, and the daemon then runs without persistence rather than failing later with a SIGBUS at the first page it touches.
An envelope published through the daemon also reaches a process that is no client of the daemon at all, one that embeds a hub of the same name. The daemon’s hub stores the envelope and writes a signal Pulse into the ring that names it and carries the hub’s own instance identity; the other hub’s poll fetches the envelope from the store by that name and delivers it to its envelope listeners. A signal the hub wrote itself is skipped, because its listeners had the envelope when it was broadcast. A signal whose envelope the store no longer holds, overwritten since it was stored, never stored, or failing verification, delivers no envelope, and is counted (unfetched_envelopes) and logged at the first and at each doubling; a Pulse the ring overwrote before the poll reached it is counted the same way (lost_pulses) and never delivered under another index or twice. The ring has one writer, the daemon’s hub, which created it: the embedded hub reads the ring and is refused with ReadOnly if it tries to publish, broadcast or route into it, with nothing stored, signalled or dispatched, and its process publishes by sending to the daemon over the port like any other client, so two writers never race for the ring’s cursor. The signals the daemon writes carry the operating system’s monotonic clock in nanoseconds, since the daemon does not drive the hub’s clock itself. Before these rules the signal left envelope_ref and the clock at zero, so a second process saw every signal and never an envelope, and a second hub on the name could have written into the ring.
Names are exclusive while a connection holds them and released the moment it ends, whether by detach, by a closed socket, or by one of the timers, together with every subscription the connection had, so nothing leaks into the hub’s registries between a client’s death and its return. A second daemon started on the same Unix socket path probes the socket before touching it: if a daemon answers, the second refuses to start and says so; only a socket file nobody answers is removed. Before this rule a second start silently took the first daemon’s socket and left it deaf.
What it costs
A timer wheel per connection, one bounded channel per connection, and a policy the operator must think about once: the defaults suit a workstation, and a busy fan-out may want a deeper queue. A Unix-socket client written against the raw protocol must send a frame within the idle window; the Rust client does this on its own.
What would refute this
A silent connection that survives its idle window, a stalled subscriber that slows a publisher, an oversized header that allocates, a name held after its connection died, a store refusal acknowledged as a success, a sixty-fifth subscription that is accepted, a connection past the cap that is admitted, a second daemon that takes over a live socket, an embedded hub that reads the daemon’s signal and never the envelope it names, or a second hub on the daemon’s name that writes into its ring would each refute the claim. The daemon’s liveness, fan-out and embed tests are the checks, and each runs in the workspace test suite.
Spoken walkthrough
A script for the video and audio version of this page, about ninety seconds.
The question a daemon has to answer is what one bad client can do to the rest. Here the answer is nothing, because every resource a connection can consume is bounded by a policy the operator sets once.
A connection is one task with a bounded queue of outbound messages. Four timers and four sizes govern it: how long it may be silent, how long a frame may take, how large a header may be, how many subscriptions it may hold. Cross a bound and the connection is closed, and told why.
Events are the one thing a client cannot be trusted to consume at the daemon’s pace, so they go through a bounded queue the publisher never waits on. Fill it and the newest event is dropped, counted, and reported back to the subscriber when it unsubscribes. Acknowledgements and errors never queue and are never dropped.
A publish the store refuses is still delivered and still acknowledged, with persisted false, and no ring signal is written for an envelope that is not there. Names are released the instant a connection ends. A second daemon on the same socket asks before it takes it.
None of this is clever. It is a list of bounds, each with a test that tries to cross it.
Trust boundaries
Explanation · for anyone deploying Pulse Mesh beyond one trusted machine, and for anyone auditing what protects what
The claim. Pulse Mesh’s integrity mechanisms detect corruption and prove content; they authenticate no one. Authentication and encryption exist, each opt-in and each at a named place: sealed UDP between nodes that know each other’s keys, TLS and proven keys on the daemon’s remote surfaces, and node keys on the links between daemons. Where none is switched on, the host’s own boundaries are the only ones, and this page says where those are.
What the integrity mechanisms prove
The CRC on a datagram detects accidental corruption in transit. It is not a signature: anyone who can send a datagram can compute a valid CRC, so a matching CRC means the bytes arrived as sent, not that a trusted party sent them. The envelope identifier proves that the content is what its name says; it does not say who produced the content, because the origin is deliberately outside the preimage. The SeqLock proves that a reader’s copy is one writer’s complete write; it assumes every process mapped into the region is trusted, because any of them could write anywhere in it. The Pulse’s chain_step and preimage fields are reserved for a replay defence that is not implemented in this release, so a Pulse carries no replay protection of its own. preimage is unused; carriers reuse chain_step (core-ring marks overruns in it, and the daemon’s links carry their hop limit in it), which proves nothing about who sent a Pulse. Replay is refused where sealing is on, at the transport, as the next section says.
What authenticates and encrypts, and where
Each of these is opt-in, by ADR-0018. Plaintext stays the default where the host already protects the path: shared memory, the Unix socket and loopback.
Sealed UDP, between nodes that know each other’s keys. A node holds one long-term X25519 key, generated on first start into a file only its owner can read, and is identified by its public key, not its name; peers are admitted by an allowlist of keys. Two nodes run a Noise IK handshake (Noise_IK_25519_ChaChaPoly_BLAKE2s, through the snow crate), then every datagram, whatever the transport would have sent in the clear, is encrypted and authenticated whole with ChaCha20-Poly1305, 36 bytes of overhead, and the inner frame keeps its CRC. A receiver drops a datagram whose seal counter is older than a window of 2,048 or already seen inside it, so replay is refused at the transport; sessions rekey every two minutes and are dropped after three idle periods; a node configured for sealing refuses plaintext from any address that is not loopback. The cryptographic-engineering review that ADR-0018 item 9 requires has not been done, so sealed mode is not yet recommended for production, and the module says so in its own documentation.
The daemon’s remote surfaces. The WebSocket is plaintext on loopback by default. With --ws-tls-cert and --ws-tls-key it is wss://: TLS 1.3 through rustls, which encrypts the connection and authenticates the daemon to clients that trust the certificate. With --attach-keys, a client attaching over the WebSocket must present a listed Ed25519 public key and answer the daemon’s challenge with a proof of it, and is then named by the key; --attach-keys-uds asks the same of the Unix socket. A --ws-bind on an address other than loopback is refused unless both are given, or unless --ws-allow-remote says the gap is meant, in which case the daemon logs a warning naming what is missing.
Links between daemons. A daemon’s links to other daemons run on sealed UDP only, keyed by its node key, and only the configured peers’ keys are admitted, each from the IP of the address its --link-peer names; a peer named by its key alone (--link-peer-key) is admitted from whatever address its handshake comes from, its key authenticated as any peer’s is. Nothing signs an envelope or its origin end to end, so what a relay says about where a forwarded send came from is as trustworthy as the relay, and a daemon takes relayed traffic only from a peer it names with --link-trust-relay. The capability Hello is the first message of each link session, inside the sealed session, so a path attacker cannot strip or forge its bits; on plain UDP nothing sends the Hello yet, and nothing acts on an agreed upgrade yet.
The enterprise ring’s TLS stage. The sender verifies the server’s certificate and name against the roots it is given; the receiver authenticates no client. A deployment that must know who connects puts that control in front of the port.
Where the boundaries are without them
Shared memory. A ring, a region and a store are POSIX objects created with owner-only permissions; every process that can open them is a peer with full write access. The boundary is the operating system’s: users and namespaces.
The daemon on its defaults. mesh-hub listens on a Unix socket with owner-only permissions and on the loopback interface, so its peers are processes of the same user on the same host. Any attached client may publish to any channel and subscribe to any filter: the daemon registers no schemas, so on its defaults there is no authorisation, and without --attach-keys no identity beyond the name a client chooses.
The UDP transport in plaintext. A data socket accepts a valid frame from any source. The feedback socket, which drives backpressure and retransmission, accepts acknowledgements and retransmission requests from any source unless BackpressureConfig::feedback_peer names one, when feedback from anyone else is dropped and counted. Source addresses can be forged, so the filter is a mitigation, not a boundary. A forged acknowledgement can only raise the sender’s cursor, which releases its stall; a forged request can only cost one bounded retransmission to the sender’s own destination. Every amplifier is bounded by design.
What is deliberately absent
Per-channel authorisation is in no layer, and any attached client may subscribe to anything. The one control on publishing is per envelope type: a hub that registers a schema with a producer list (mesh-schema, through the hub’s API; the daemon’s flags register none) refuses that type from any other node, by key when clients prove keys at attach. A schema’s consumer list is recorded and not enforced. Nothing signs an envelope’s origin end to end. Neither is in the substrate because the standard’s contract is the bytes, and a second thing to agree on before the bytes could agree would be a second standard; authority above the transport belongs to the layers the Sovereignty stack puts around it, or to the deployment.
What would refute this
A deployment guide that presented the CRC or the identifier as authentication; a daemon that bound a routable address without TLS and attach keys and without a warning; a sealed receiver that accepted a replayed datagram, or a plaintext frame from a non-loopback address; a link that admitted an unlisted key, or relayed traffic from a peer not named as a relay; or a feedback path with an unbounded amplifier would each contradict this page. The transport’s sealed-decode fuzz target and replay tests, the daemon’s attach-key and --ws-allow-remote tests, the link tests and the feedback-filter and queue-bound tests are the checks for the mechanisms; the first claim is checked by reading the code this page cites.
Spoken walkthrough
A script for the video and audio version of this page, about ninety seconds.
Three things in Pulse Mesh prove something, and none of them proves who you are talking to. The checksum proves the bytes arrived as sent. The identifier proves the content is what its name says. The ring’s counter proves a reader got one writer’s whole write. Useful, and not authentication.
Authentication and encryption exist, but you switch them on, at a named place. Between machines over UDP, sealing: each node has a key, peers are listed by key, and every datagram is encrypted and authenticated, replays refused. On the daemon’s WebSocket, TLS for the connection and a proven key for the client. On the links between daemons, the same node keys. Two caveats come with the first: the independent review the design requires has not happened yet, so sealed mode is not yet recommended for production; and a relay’s word about where a message came from is only as good as the relay, so you name the relays you trust.
Where nothing is switched on, the boundaries are the host’s: who can open the shared memory, who can reach the socket. And one thing is in no layer at all: per-channel permission. A client that may attach may publish on any channel. That is the deployment’s to add, and this page is where it says so.
Decision log
Explanation · generated · the architectural decision record under `decisions/`, in order, never rewritten
Each record states its context, the decision and its consequences, and stays in place when a later record supersedes it. The index below is read from the files; the files are the record.
A non-obvious call (where something belongs, why a crate is shaped the way it is, anything a later contributor would otherwise reverse-engineer from a pull request) gets a new record rather than only an explanation in the pull request. Number it one past the highest present, and say in its body which earlier record it supersedes, if any. Numbers are never reused, so a gap is a retired record.
| ADR | Title | Status | Date |
|---|---|---|---|
| ADR-0012 | The Rust client is a tier-2 crate | Accepted | 2026-09-25 |
| ADR-0014 | Pre-integration crates say so | Accepted | 2026-09-25 |
| ADR-0017 | Partial crates say so, and the architecture check holds them to it | Accepted | 2026-10-17 |
| ADR-0018 | The security model: what is protected, by what, and what is not | Accepted | 2026-10-17 |
| ADR-0021 | Daemons that link: a mesh across hosts | Accepted | 2026-10-17 |
Changing the tree
Changing the tree
The workspace, the gates, the decision record, and the rules this book holds itself to.
Part opener · for people changing code or documentation
Contributions go through the same gates whether they change code or prose: formatting, lints, the tests, the rustdoc link check, and for the book the structural checks that keep every page reachable, every link resolving, every displayed version equal to the workspace version, and internal process language out of released text.
- Developer Onboarding: the workspace, the branch and PR flow, the gates.
- Documentation style: the page kinds, the voice, and the review checklist for anything written.
- Decision log: the decision record under
decisions/and how to add to it.
Documentation style
Contributing · for anyone writing or reviewing a page of this book, a README, a crate overview or a message a user will read
The book is held to the same standard as the code: every page has one job, states it, and can be checked. This page is the standard. The review checklist at the end is what a documentation pull request is read against.
One page, one kind
Every page is exactly one of six kinds and says which in its first line, in the label the theme renders from <p class="pm-kind"><b>Kind</b> · for whom</p>.
| Kind | Job | Shape |
|---|---|---|
| Example | Show it working | Opens with what the reader will have and what they need; one self-contained prompt for a coding agent; the same steps by hand with the literal output of each; a table of checks with pass conditions and what failures mean; what happened, including what the code does not do; variations; a spoken script. |
| Workflow | Do one domain’s job end to end | An example’s shape, for one domain: robotics, enterprise, HFT or distributed compute. States which parts of the path are implemented and which are deferred. |
| Tutorial | Teach by doing | States what it assumes and what the reader will have at the end; numbered steps; every command run by the documentation gate; ends with what was learned. |
| How-to guide | Get a task done | Names the task, the prerequisites, the steps in order with the expected result after each; links out for background and facts; no teaching. |
| Reference | State what is true | Complete, precise, generated from the code where a table can be; no persuasion, no steps. |
| Explanation | Say why | One claim, the mechanism that makes it true, one figure, and a closing section on what would refute it. |
A page that wants to do two jobs is two pages.
Voice
The voice is the Foundation’s, as it stands on the Foundation’s website.
- Declarative and precise. State the claim, then the evidence. “A reader that sees an odd counter retries” rather than “readers will generally want to retry”.
- One idea per sentence. Short sentences carry weight; a sentence that needs a semicolon is two sentences.
- Institutional, not personal. No “we think”, no “I”, no “the author”. Tutorials and guides address the reader as “you”; reference and explanation pages address no one.
- No superlatives, no hedging, no marketing. “The ring publishes without a system call” rather than “blazingly fast”.
- Define a term before using it, and use the glossary’s word for it every time. Pulse, PulseEnvelope, the daemon,
pms, the four tiers, the dev ring: never a synonym. - Numbers carry units and a source. A measurement names the command that produced it.
- What is not yet true is said plainly. A specified behaviour that the code does not implement yet is named as such, with the test that will prove it.
- Superseded material stays in place and is marked; nothing is silently rewritten. Decisions go to
decisions/as append-only records. - Every explanation ends with what would refute it. Refutations are welcome and the page says so.
- American spelling. Present tense. Second person only in tutorials and guides.
Names that are welcome and names that are not
The Sovereignty stack’s names are public and are used deliberately: Canon, the Lexicon, Kronos, Sov OS, Musashi, Knox, Aegis, Sage, RITA, Hermes, Owl, WISP. The glossary maps each to the component that carries it here or says it is not part of this repository.
The names the registry lists as marks (Pulse Mesh™, PulseEnvelope™, Canon™, Lexicon™, Kronos™ and the rest; the list is at sovfound.org/registry) carry ™ on their first prominent use on a page: the hero, a part opener, a glossary entry, the first mention in prose. Later mentions on the same page do not repeat it.
What released text does not carry is the language of how the tree was made rather than what it is: internal phase labels, migration process notes, and names of documents or features that no longer exist. scripts/docs/lint-terms.py holds the list and runs in the documentation gate.
Structure
- Headings are phrased as the reader’s question or task in tutorials and guides (“Publish an envelope”, “What you will need”), and as the noun they describe in reference and explanation (“Slot layout”, “Why two reads”).
- Code comes before prose in a how-to guide: the command, then what it does.
- A page stays under about 1,500 words. A page that grows past it splits along its headings.
- Rust samples are included from the examples with
{{#include}}anchors rather than typed into the page, so the sample and the page cannot drift. Shell transcripts show the command and the output the reader should see. - Every reference table that can be generated from the code is generated, and the gate regenerates it and fails on a difference.
- One figure per explanation page, from the website’s catalogue where one exists, with a caption in the label idiom (
<figure class="pm-figure">). Colors mean what the legend says: cyan the Pulse, violet the envelope, emerald the substrate, amber the transport, rose an error, slate what is set aside. - Links are relative and point at pages of this book, at files in the tree, or at the decision record. The gate checks that every link resolves and every page is reachable from the summary.
Markdown conventions
One paragraph is one line; prose is never hard-wrapped at a column. List items and table rows are one line each. Code blocks name their language. Files begin with a single # heading and then the page kind line (part openers and the introduction carry their heading inside the hero block, with the kind line after it).
The review checklist
A documentation pull request is read against these questions, and each one is answered before it merges.
- Is the page exactly one kind, and does its first line say which and for whom?
- Does every command in it run in the documentation gate, and does the output shown match?
- Is every fact traceable to code, a test, a fixture, or a bench command named on the page?
- Does an explanation page make one claim, show one figure, and say what would refute it?
- Are all terms the glossary’s terms, used in the glossary’s senses?
- Does the page carry no internal process language, no persona, and no name of something that does not exist?
- Do the version, the links and the summary pass the gate (
just docs)? - Is anything superseded marked rather than deleted, and is any decision it records in
decisions/? - Is it under the length limit, or split?
- Would a reader who knows nothing of how this tree was made understand every sentence?
- Is every claim about what the code does, or lacks, checked against the code itself rather than against a comment, an earlier page or a review? A comment can be wrong from the day it was written, and a claim that is only ever checked against other claims is never refuted.