Files
sausage/PROTOCOL.md
T
SAU-359 implementer ff6fc62f05 Add armed plugin-originated file watches across VFS mutations
Register core.watch before its armed notification, coalesce later changes
without losing post-notification writes, and clean up on caller death.
Observe mounted layers and open file handles, including descriptor/stream
writes, truncation, both rename paths and deletion. Track cancelled
mutations through process idle and retain visible paths across renames.
Document the protocol, expose native core discovery, and exercise watches
through SDK callbacks and raw WASI filesystem operations.

Card: SAU-359
2026-10-30 20:16:20 +00:00

28 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 and 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 name core is reserved.
  • The world folder must not contain proc, run, or self. 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/command component for wasm32-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.
  • params is 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.add starts a new process of notes and sends it the request add. notes.rpc.discover sends it rpc.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, and rpc.endpoint.

3.3 What a call does

  1. The core gives the call a UUIDv7 and starts the process <plugin>-<uuid> with the current version of the plugin.
  2. 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.
  3. The process writes the response to stdout. The core sends the response to the caller, with the caller's request ID.
  4. If the process ends before it answers, the caller gets the error -32002. Its data has 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:

  1. running
  2. 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.
  3. 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):

  1. New calls get the new version at once.
  2. 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 default is 60 seconds. A plugin can declare its own value in its old rpc.discover document (x-sausage-grace).
  3. When the old process of a service has ended, the core starts the service again with the new version.
  4. 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 /proc always fail.
  • In a process folder, the names info.json, log, and fs belong to the core. A write to /self/info.json fails.
  • A read of a generated file returns a snapshot that ends. For example, /proc/<pid>/log returns 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 /self path to another process. There it means a different folder, like localhost on 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. The watch ends with its caller; there is no cancellation API or polling/retry loop.

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.sqlite stays 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 (stderr or core), 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>/log shows 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, the core writes the error to the journal and continues.
  • After an upgrade of the plugin of a service, the core starts the service again with the new version (section 3.7).
  • 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-params for each method with params: a $ref to the schema of the params object.
  • x-sausage-endpoints: the endpoint interfaces by name, each with its methods.
  • x-sausage-imports: for each provider, the part of its document that the plugin uses.
  • x-sausage-grace: the grace period in seconds.

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:

  1. The provider has the method.
  2. Each params value that the consumer can send is valid for the provider.
  3. 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 properties and required, 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, and core.revert restores only code. A plugin must keep its state format compatible, or migrate it in rpc.retired.

10.3 Activation

core.activate {"plugin": "notes", "wasm": "/state/cargo/builds/7/notes.wasm"}:

  1. The core copies the file. All further steps use this copy, even if the file changes.
  2. It starts the copy and calls rpc.discover.
  3. 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.
  4. It checks the imports of the new version against the current providers.
  5. It checks each current consumer of the plugin against the new version.
  6. If all checks pass, plugin.wasm becomes plugin.prev.wasm, and the copy becomes plugin.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, or null on 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" with force: 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: true skips steps 4 and 5.
  • core.revert {plugin} activates plugin.prev.wasm in the same way, and the current version becomes plugin.prev.wasm.
  • A person can also copy a file to plugin.wasm by hand. That skips the checks.

10.4 The graph

  • The core keeps the rpc.discover documents of all plugins, by file hash. At start, it gets them in the background.
  • A file watcher on /plugins/*/plugin.wasm updates them, for example after a copy by hand. /proc/core/graph.json then 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/:

  1. It copies the source once. The tests and the build both use this copy.
  2. It runs the tests under WASI, in the release profile.
  3. It builds the release .wasm for wasm32-wasip2. The tests and the build share the compiled dependencies.
  4. 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.log and .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. /logs is a top-level world folder, created if missing, not under /state.
  • Success results and error -32004 data include logs: {"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. logs is 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_secs is 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/message timeout, with data.reason, data.elapsed_secs, data.step (tests or release build), data.tests, and data.output containing the last 50 output lines.
  • Optional test_timeout_secs is 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 with timed 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.build gets a process ID, a folder in /proc, and journal entries, like other calls.
  • /plugins/cargo/sdk/ is the SDK used by builds. The dependency sausage-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_wasip2 and AR_wasm32_wasip2 point to a WASI clang.
  • The build does not activate the result. Call core.activate next.

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 through env!("CARGO_MANIFEST_DIR").
  • The test runner answers core.lock, core.unlock, core.notify, and core.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.