31 KiB
The Sausage protocol
This is the contract between the core and the plugins. A plugin in any
language that follows it works in a Sausage world. The core shows this file
at /proc/core/PROTOCOL.md. DECISIONS.md in the repository says why the
contract is the way it is.
Rust plugins use the SDK sausage-plugin. Its source is at
/plugins/cargo/sdk/, and its crate docs explain each item.
1. Terms
| Term | Meaning |
|---|---|
| world | The folder that a sausage host runs. It is / for every process. |
| core | The host. It runs processes, routes calls, serves the virtual files, and keeps the journal. |
| plugin | A folder /plugins/<name>/ with a plugin.wasm. A plugin does not run by itself. |
| process | One run of a plugin version. Its ID is <plugin>-<uuidv7>, for example notes-0197f3a2-7c1e-7d2b-9a41-5f0c2e8b1d34. |
| call | A JSON-RPC request. notes.add calls the method add of the plugin notes. |
| interface | A set of methods. A plugin has one main interface. It can also declare endpoint interfaces. |
| endpoint | An interface that a running process serves on a TCP port. An endpoint file tells others where it is. |
| service | A call in /config/boot.yaml. The core starts it at boot, restarts it after a crash, and starts it again after an upgrade of its plugin. |
| import | An interface of another plugin that a plugin uses. |
| provider, consumer | The plugin that has an interface, and a plugin that imports it. |
| journal | The central log of the world. |
2. The world folder
<world>/
plugins/<name>/
plugin.wasm the current version
plugin.prev.wasm the version before, for `core.revert`
source/ the source. `cargo.build` builds it from here.
imports/<plugin>.json descriptions of the imported plugins
state/<plugin>/ persistent state of a plugin, by convention
logs/core/journal.sqlite the journal
state/core/ console history (state, not logs)
config/ global configuration
boot.yaml the services
agent.yaml the API, base URL, model, and key of the agent
- Every process can read and write all of it. There are no permissions.
- Configuration files are YAML, with comments. Data and interface files are JSON.
- Plugin names use
a-z,0-9,-, and_. The namecoreis reserved. - The world folder must not contain
proc,run, orself. These names are mount points (section 4). The host does not start a world that has them.
3. Calls and processes
3.1 The wire protocol
- A plugin is a
wasi:cli/commandcomponent forwasm32-wasip2. - stdin and stdout carry JSON-RPC 2.0, one message per line.
- A line is a message only if it is a JSON object with
"jsonrpc": "2.0". The core discards every other line on stdout. For the first discarded line of a process, it writes one warning to the journal. - Each line on stderr goes to the journal. Log to stderr.
paramsis always an object. Batches are not supported.- Write each message in one write, and write a newline before it. Then a
print!without a newline cannot damage a message.
3.2 Method names
- A method name has the form
<target>.<method>. The core splits it at the first.. notes.addstarts a new process ofnotesand sends it the requestadd.notes.rpc.discoversends itrpc.discover.core.<method>is a method of the core (section 9).- Method names that start with
rpc.are reserved:rpc.discover,rpc.superseded,rpc.retired, andrpc.endpoint.
3.3 What a call does
- The core gives the call a UUIDv7 and starts the process
<plugin>-<uuid>with the current version of the plugin. - The core writes the request to the stdin of the process. The request ID is the process ID, and the method has no plugin prefix.
- The process writes the response to stdout. The core sends the response to the caller, with the caller's request ID.
- If the process ends before it answers, the caller gets the error -32002.
Its
datahas the exit status and the last journal lines of the process. Before the core decides this, it reads the stdout of the process to the end.
- A process can write calls to stdout at any time, to the core and to plugins. The responses come on stdin. A process can have many calls in flight.
- The core keys each pending request by the process and the exact JSON ID. The core drops a response with an unknown or late ID.
- A notification to
plugin.method(without an ID) starts an attached process. That process gets the notification without an ID, and nobody gets a result.
3.4 Detached calls
core.call {"method": "www.start", "params": {...}, "detach": true} makes the
call and returns its result to the caller. The new process has no parent, so
it does not end with its caller.
Sent as a notification (without an ID), the same core.call starts the
process and forgets it.
3.5 Notifications to the caller
A process can tell its caller about progress. It sends the notification
core.notify {"name": "text", "params": {...}}. The core gives it to the
caller as core.notify {"call": <ID of the caller's request>, "name": "text", "params": {...}}. After the call has ended, the core drops such
notifications.
So a process never needs to know who called it.
3.6 Lifetime
A process lives until main returns. A process that uses the SDK lives
while one of these runs:
- a handler of a call,
- a background task,
- an endpoint that listens.
A process has three states:
- running
- stopping: the core has closed stdin. EOF on stdin means "stop now".
- New calls of the process fail at once, and its pending calls fail.
- A stop hook can still write files and send notifications. It cannot make calls, because no response can arrive.
- After 5 seconds, the core kills the process.
- ended: the core releases the locks of the process (section 6) and
removes its folder in
/proc.
Rules:
core.stop {pid}starts the stopping state.core.kill {pid}kills the process at once.- When a process ends, the core stops each of its attached children. All children stop in parallel, so a whole tree takes 5 seconds at most.
- A detached process has no parent. Only
core.stop,core.kill, an upgrade (section 3.7), and the end of the host stop it. - At host shutdown, the core starts no new processes, and it stops every process in parallel.
Note: on Unix, children outlive their parent. Here, an attached child ends
with its parent. Only detach makes a process independent.
3.7 Upgrades
A process is an instance of one plugin version. When a new version becomes active (section 10.3):
- New calls get the new version at once.
- At grace start, the core sends each process of an older version the
JSON-RPC notification
rpc.superseded {"current": "<sha256 of the new file>"}on stdin, without an ID. The process is still running: calls and responses continue to work. It can stop accepting work, finish its in-flight calls, and exit early. Ignoring the notice changes nothing. The grace period is the upper bound before the core stops the process (section 3.6); its duration is 5 seconds for every plugin. Discovery metadata, including an explicitx-sausage-grace, does not change it. After grace, stdin closes and the separate 5-second stop budget applies (section 3.6): at most two 5-second windows. Discovery-only processes do not receive this notice or enter upgrade grace; they follow the discovery lifetime (section 10.1). - When the old process of a service has ended, the core starts the service again with the new version.
- When the last process of an older version has ended, the core starts a
process of the new version and calls
rpc.retired {"previous": "<sha256 of the older file>"}. A plugin can use this hook to migrate its state, or to write a marker that its other new processes wait for. The result goes to the journal.
During the grace period, an old process can call a method that a new provider no longer has.
If the host stops before the last old process has ended, rpc.retired does
not run for that version. So a new version must also work with state that
was not migrated. Check for a migration marker, and migrate when it is
missing.
3.8 Environment and network
SAUSAGE_PID: the process ID.SAUSAGE_PLUGIN: the plugin name.SAUSAGE_PARENT: the parent:console,core, or a process ID.
Every process gets the network: outgoing HTTP and HTTPS through wasi:http,
and TCP, UDP, and DNS through wasi:sockets.
A process that computes for a long time does not block other processes. The core interrupts it every 10 milliseconds.
3.9 Errors
The standard JSON-RPC codes apply. The core adds these codes:
| Code | Meaning |
|---|---|
| -32001 | There is no such plugin. |
| -32002 | The process ended without an answer. data has the exit status and the last journal lines. |
| -32003 | The activation was rejected. data has the reasons. |
| -32004 | cargo.build failed. data has the output of the failed step and the tests. |
4. The filesystem
Each process gets one preopen, /. The core resolves each path through a
mount table:
| Path | What it is | Access |
|---|---|---|
/ |
the world folder | read-write |
/proc |
generated by the core | read-only |
/self |
the folder /proc/<own pid>/, in memory |
read-write |
/run |
in memory, shared, empty at each host start | read-write |
A listing of / shows proc, run, and self. A rename across two of
these areas fails.
/proc/
core/
PROTOCOL.md this file
discover.json core.rpc.discover document
graph.json plugins, imports, and the result of each check
graph.dot the same graph for Graphviz
locks.json held locks and waiting processes
plugins/<name>/
discover.json the rpc.discover document of the current plugin.wasm
client.rs the Rust code that import!("<name>") generates
<pid>/
info.json plugin, version, method, params, parent, detached, start time, state
log the journal lines of this process
<files> the files that the process writes to /self
Rules:
- A process writes its own folder only through
/self. Writes through/procalways fail. - In a process folder, the names
info.json,log, andfsbelong to the core. A write to/self/info.jsonfails. - A read of a generated file returns a snapshot that ends. For example,
/proc/<pid>/logreturns the lines up to the time of the open. - The folder of a process goes away when the process ends. Its files are in
memory. Large or persistent data belongs in
/state. - Do not send a
/selfpath to another process. There it means a different folder, likelocalhoston another machine.
Principle: inspect through files, act through calls.
File watching
core.watch {"path": "/state/session/mailbox"} stays open for the caller
process's lifetime. It needs a request ID and a running plugin caller.
Its first core.notify is "armed", with empty params. Only then scan the
files. Writes before arming are visible in that scan; writes after arming
produce "changed" notifications (also with empty params). Rescan after
each notification; files, not event counts, are the truth.
Notifications follow successful plugin-originated file mutations: creation,
truncation, descriptor/stream writes, rename (both source and destination),
and deletion, including mounted /run and /self files. Paths are normalized
in the virtual namespace; /self means the caller's /proc/<pid> folder.
A watch matches itself and descendants, not sibling prefixes. Moving or
removing an ancestor also invalidates it. Paths need not exist when armed.
Open file writes follow plugin renames; unlinked/replaced handles no longer
name visible files. External host writes are outside this contract.
Changes may coalesce, including a change already visible in the initial scan, but a change after the last notification always produces another. Delivery holds no filesystem locks. Observation does not serialize descriptor IO; only overlapping opens and rename/removal wait for one another. Watch the narrowest useful path: delivery is not rate-limited, so watching a busy parent can cause a rescan for every write.
The watch ends with its caller; there is no cancellation API or polling/retry loop. SDK callers should poll the watch future alongside their handler's work and drop it on return, not keep it in a background task that prevents an idle process from exiting. Dropping the future does not cancel the host watch.
5. Endpoints
An endpoint is a JSON-RPC interface that a running process serves over TCP, for example the control interface of a web server.
- The process binds a free port on 127.0.0.1. When the listener is ready, it
writes an endpoint file in one atomic step (write a temporary file, then
rename it). The file has one line: the address, the owner process, and the
interface. Example:
tcp:127.0.0.1:41234 www-0197… control. - Usually the file is in
/self. A service that others must find uses a fixed path in/run, for example/run/secrets. - A client reads the file and connects. Its first request is
rpc.endpoint, which returns{"owner", "plugin", "interface"}. If they do not match the file, the client disconnects. So a stale file or a reused port cannot connect a client to the wrong process. - JSON-RPC runs on the connection, one message per line, in both directions.
Notifications of a handler (
core.notify) go over the connection. - A plugin declares its endpoint interfaces in
rpc.discover(x-sausage-endpoints).
6. Locks
core.lock {"path": "/state/notes/notes.json"}returns{"held": true}when the caller holds the lock.- Each acquisition gets its own place in a FIFO queue. The same process cannot take one lock twice: its second acquisition waits behind its first.
- With
"wait": false, the call returns at once with{"held": true}or{"held": false}. core.unlock {path}(a notification) releases the lock.- When a process ends, the core releases all of its locks, also after a crash or a kill. It waits until the last file operation of the process has returned. So a write of a killed process cannot land after the next process got the lock.
- Locks are advisory. The core does not block writes to the file: the path is only a name.
- SQLite has no file locks in WASI. Hold the lock while the database is open.
7. The journal
- New entries go to
/logs/core/journal.sqlite. Only the core writes it. An existing/state/core/journal.sqlitestays in place and is opened read-only; queries merge both databases by entry ID before applying the limit. The host never renames a live SQLite/WAL trio. Console history stays in/state/core. No journal or log files are automatically pruned. - If a journal transaction fails, the complete batch (including original
IDs and timestamps) is saved in an exclusive, synced JSON file under
/logs/core/failed-batches/; the host reports its path on stderr. These files are recovery artifacts, not query results: recovery/import is deliberate, using IDs to avoid duplicates, and never deletes the source. If spooling also fails, entries stay in memory and retry on the next command (no polling); at writer shutdown their full text is reported to host stderr. Storage failure plus host loss can still lose unsaved data. - Each entry has a UUIDv7 (it sorts by time), the process ID, the plugin, the
stream (
stderrorcore), and the line. - Traps retain their full diagnostic text in the journal; short exit-status displays are UTF-8-boundary-safe.
- The core writes its own events too: start (with method and params), exit status, stop, kill, upgrades, and the warning about discarded stdout.
core.log {plugin?, pid?, since?, until?, grep?, limit?}queries the journal./proc/<pid>/logshows the lines of one running process.
8. Boot and services
At start, the core starts each service in /config/boot.yaml, in order:
- method: secrets.serve
params: {}
- method: www.start
params:
port: 8080
- A service is a detached call with the parent
core. The core waits for its result before it starts the next one. The result shows that the service is ready. - If a service fails before answering, the core writes the error to the journal and continues booting. A failure to admit a process (for example, a missing plugin or invalid parameters) is logged, not retried in a loop.
- The core supervises the process lifetime, not its readiness response. A nonzero exit, trap or failed host task restarts the same service with its original method and parameters, including a crash before readiness. A readiness response alone never triggers a restart; a clean exit does not count as a crash or trigger an immediate restart.
- More than 5 crashes within 10 seconds gives up on that service and writes
service gave upto the journal. Each service has an independent sliding window; crashes exactly 10 seconds old expire. There is no retry delay or periodic liveness check. Giving up is latched for this host lifetime: neither elapsed time nor an upgrade re-arms it. - After an upgrade of the plugin of a service, the same supervisor starts
it again with the current version after its old process ends (section
3.7). Superseded exits are retirement, not crashes. Admission and cleanup
reserve a single owner/attempt, so an upgrade racing a crash cannot start
two owners. A matching boot-service start from
rpc.retiredis ignored (returnsnull), even while restart is pending or after giving up; older plugins' fallback starts do not bypass supervision. - An explicit
core.stoporcore.killdisables automatic attempts for that service, including during an upgrade. Host shutdown starts no new processes. The core does not restart other detached processes. - A singleton is a service with a fixed endpoint in
/run. Only the core starts it, so two instances cannot start at the same time.
9. The core API
| Method | Params | Result |
|---|---|---|
core.rpc.discover |
none | OpenRPC core API document (also /proc/core/discover.json) |
core.call |
method, params, detach? |
the result of the method |
core.watch |
path |
stays open; armed, then changed notifications (section 4) |
core.notify |
name, params |
none (a notification) |
core.stop |
pid |
{} when the process has ended |
core.kill |
pid |
{} |
core.lock |
path, wait? |
{"held": bool} |
core.unlock |
path |
none (a notification) |
core.activate |
plugin and wasm, or plugins: [{plugin, wasm}]. force? |
short activation summaries, keyed by plugin |
core.revert |
plugin, force? |
like core.activate |
core.log |
filters (section 7) | journal entries |
core.restart |
none | {}. With SAUSAGE_LOOP=1, schedule clean shutdown and exit code 75. |
core.exit |
none | Alias of core.restart: same supervisor gate, response, coalescing, and exit code 75. |
Host restart
HARD RULE: the mediator and all agents NEVER call core.restart without
the person's explicit approval each time. The same approval rule applies
to the legacy alias core.exit; it uses the same gate and pending request.
core.restart {} is enabled only when the host starts with SAUSAGE_LOOP=1.
The host captures the marker at startup. An absent marker (or any value other
than 1) returns error -32600: restart needs the loop supervisor (SAUSAGE_LOOP not set); start with SAUSAGE_LOOP=1, without scheduling an exit.
This is a supervisor marker, not a permission system.
The method returns {} immediately and schedules the shutdown request after
250 milliseconds to let the response reach the caller before stdin closes.
Repeated requests while a restart is pending return {} without scheduling
another restart or extending that delay. The executable then uses the same
Host::stop path as a normal stop: reject new processes, stop all processes
(including detached ones) in parallel with the normal five-second grace,
wait for outstanding file operations, release locks, and drain journal writes.
Only after that does the executable return the dedicated restart exit code
75. The library signals this path; it never kills its embedding process.
nix run .#loop -- <world> sets SAUSAGE_LOOP=1 on the host invocation and
starts the newest host again after exit code 75 (and, as before, other nonzero
exits). Exit code 0 ends the loop. If using an older loop script, the person
must set SAUSAGE_LOOP=1 in that supervisor's environment; do not set it for
an unsupervised host. The NixEnv service invokes this flake's #loop, so a
new loop script supplies the marker without changes to NixEnv.
Everything else is a file.
10. Interfaces
10.1 rpc.discover
Each plugin answers rpc.discover with an OpenRPC 1.2 document. The schemas
are JSON Schema Draft 7, as OpenRPC requires. Shared schemas are in
components.schemas, and $refs point to #/components/schemas/<Name>.
Params are by name. The document can have these extension fields:
x-sausage-paramsfor each method with params: a$refto the schema of the params object.x-sausage-endpoints: the endpoint interfaces by name, each with itsmethods.x-sausage-imports: for each provider, the part of its document that the plugin uses.x-sausage-grace: legacy grace declaration in seconds. The core ignores this field; upgrade grace is always 5 seconds (section 3.7).
The schema of params describes what the provider can read. The schema of a result describes what the provider writes. Descriptions are part of the interface: other agents read them to use it.
The core starts a process only to call rpc.discover, and it stops that
process after the answer. Do not do expensive work at start. If no answer
comes within 10 seconds, the discovery fails.
10.2 Compatibility
A provider can add methods, optional params, and result fields. For each imported method:
- The provider has the method.
- Each params value that the consumer can send is valid for the provider.
- Each result that the provider can return is valid for the consumer.
- The same rules apply to endpoint interfaces.
- There are no version numbers. This check replaces them.
- The check covers a fixed subset of JSON Schema: objects with
propertiesandrequired, the primitive types, arrays, optional and nullable values, string enums, integer ranges, and$ref. A construct outside the subset fails the check. - The check covers calls, not state. Old and new processes share the files in
/state, andcore.revertrestores only code. A plugin must keep its state format compatible, or migrate it inrpc.retired.
10.3 Activation
core.activate {"plugin": "notes", "wasm": "/state/cargo/builds/7/notes.wasm"}:
- The core copies the file. All further steps use this copy, even if the file changes.
- It starts the copy and calls
rpc.discover. - It gets the current documents of all plugins (section 10.4). A plugin whose discovery fails is "unavailable". Its imports cannot be checked, but it does not block the activation of other plugins.
- It checks the imports of the new version against the current providers.
- It checks each current consumer of the plugin against the new version.
- If all checks pass,
plugin.wasmbecomesplugin.prev.wasm, and the copy becomesplugin.wasm. Then the upgrade starts (section 3.7). If the copy has the same hash as the current version, nothing changes.
If a check fails, nothing changes, and the error -32003 lists each problem, with a counterexample where possible.
The result is an object keyed by plugin name. Each value contains:
plugin: the plugin name.wasm: the installed world path (/plugins/<name>/plugin.wasm).hash: the SHA-256 of the installed copy.previous: the replaced version's SHA-256, ornullon first installation. For an identical-hash no-op, this is the current hash.method_count: the number of methods in the new main interface.added,removed,changed: sorted method-name arrays compared with the previous discovery document. A change to a method description or any transitively referenced local schema counts as changed. Unrelated schemas and endpoint interfaces do not affect these arrays. With no previous document, all methods are added.interface_check:"passed"after successful checks, or"skipped"withforce: true(not a claim of compatibility).
The result does not include schemas. The full document remains available
through <plugin>.rpc.discover and /proc/core/plugins/<name>/discover.json.
An identical-hash no-op and core.revert return the same summary shape.
- The core runs one activation at a time.
plugins: [...]activates several plugins together. The checks then use the new set.force: trueskips steps 4 and 5.core.revert {plugin}activatesplugin.prev.wasmin the same way, and the current version becomesplugin.prev.wasm.- A person can also copy a file to
plugin.wasmby hand. That skips the checks.
10.4 The graph
- The core keeps the
rpc.discoverdocuments of all plugins, by file hash. At start, it gets them in the background. - A file watcher on
/plugins/*/plugin.wasmupdates them, for example after a copy by hand./proc/core/graph.jsonthen shows each broken import, and the journal records it. - An import of a plugin that does not exist is "missing". An import of a plugin whose discovery failed is "unavailable".
11. Building and tests
The core has one native plugin: cargo. cargo.build {"plugin": "notes"}
builds /plugins/notes/source/:
- It copies the source once. The tests and the build both use this copy.
- It runs the tests under WASI, in the release profile.
- It builds the release
.wasmforwasm32-wasip2. The tests and the build share the compiled dependencies. - It returns
{"wasm": "/state/cargo/builds/<n>/notes.wasm", "tests": {"passed", "failed", "output"}, "warnings", "logs"}. If a step fails, it returns an error with the compiler or test output.
- Before running commands, it creates
/logs/cargo/<YYYY-MM-DD>/<build-id>.stdout.logand.stderr.log(UTC date, unique build ID). Both test and release phases append their raw bytes directly to these files, before any parsing, progress filtering or UTF-8 conversion. This includes successful JSON build output./logsis a top-level world folder, created if missing, not under/state. - Success results and error -32004
dataincludelogs:{"stdout":{"path":"/logs/cargo/...stdout.log","bytes":123}, "stderr":{"path":"/logs/cargo/...stderr.log","bytes":45},"total_bytes":168}. Counts are raw bytes, not characters.logsis null only if log creation itself fails. Preparation failures otherwise include empty log files. Inline diagnostics may be filtered or shortened; the files are the full truth. - Logs remain after failure, total/test timeout, and cancellation. Cancellation
need not return a result; its files remain discoverable in
/logs/cargo/. Pruning numeric/state/cargo/builds/artifact folders does not prune logs. - Optional
timeout_secsis an integer of at least 1, default 900 (not null or unlimited). The wall-clock budget starts when the build acquires its mutex, excluding queue time, and covers tests and release compilation together. On expiry, cargo and its descendant process group are killed and the mutex is released. Error -32004 has reason/messagetimeout, withdata.reason,data.elapsed_secs,data.step(testsorrelease build),data.tests, anddata.outputcontaining the last 50 output lines. - Optional
test_timeout_secsis an integer of at least 1, default 120. Each WASI test instance has this wall-clock budget. On expiry, the instance is killed, its named test fails withtimed out after N s, and remaining tests continue. The overall build budget still applies. - Cancellation kills cargo's process group, including build scripts and test runners, before releasing the build mutex. An attached build also stops when its caller ends (under the normal process stopping deadline).
- A call to
cargo.buildgets a process ID, a folder in/proc, and journal entries, like other calls. /plugins/cargo/sdk/is the SDK used by builds. The dependencysausage-plugin = "0.1"points directly to this folder, including its macros and interface crate. Edits apply to the next build without a host restart.- The host seeds a missing SDK folder from its embedded copy at startup or before a build. An existing folder is never overwritten or repaired, even after a host upgrade. To reset to the host's bundled SDK, remove the whole folder; the next startup or build re-seeds it. Back up local edits first.
- Compiled crates stay in the host's cache, outside the world, and all worlds share them. So a second build compiles only what changed.
- Build scripts can compile C:
CC_wasm32_wasip2andAR_wasm32_wasip2point to a WASI clang. - The build does not activate the result. Call
core.activatenext.
Tests:
- Each test runs in its own instance, so a panic ends only its own test.
- The normal Rust test setup works:
#[test],tests/*.rs, name filters, and--ignored.#[should_panic]cannot work, because WASI cannot catch a panic. - Each test gets an empty temporary world as
/, the package folder at its host path, and its own/proc,/self, and/run. - The current folder of a test is
/, not the package folder. Read fixture files throughenv!("CARGO_MANIFEST_DIR"). - The test runner answers
core.lock,core.unlock,core.notify, andcore.log. A call to a plugin fails: a test has no other plugins.
12. Embedded agent tool output
The agent shipped by sausage init uses a context-size guard of 50 KiB or
2000 lines, not a data-retention limit. Oversized tool results, including
errors, are saved in full under /logs/agent/<date>/default/ before history
and provider delivery receive an excerpt. Overflow JSON string values are
rendered as physical text lines so the reference's one-based inclusive line
ranges can be followed with read_file {path, line_offset, line_limit}
(zero-based offset; default 200 lines). Huge single lines give exact byte
ranges and continuation offsets. Command/log/build/test results show the
tail; other tools show the head. Reading an existing /logs file excerpts
that same file without making another archive. Archives are exclusive and
never automatically deleted. If archival fails, the agent reports the error
to stderr and keeps the complete result instead of discarding it. Existing
history formats and paths are unchanged.