Files
sausage/DECISIONS.md
T
SAU-135 implementer 168795b408 seed: carry restart approval policy into new worlds
Make legacy core.exit share core.restart's supervisor gate and pending
request. Seed the stable agent prompt and boot comments with the approval
and shutdown contract, and check real init output under a deadline.
Cover both aliases' gate, response grace, coalescing and exit status.

Card: SAU-135
2026-10-26 10:29:12 +00:00

6.1 KiB
Raw Blame History

Decisions

Why Sausage is the way it is. PROTOCOL.md and the code own the details. When this file disagrees with them, they win.

The shape

  • JSON-RPC, not component interfaces. Every language can read and write lines of JSON, and models know JSON-RPC from LSP and MCP. A call has a result, like an LLM tool call.
  • A process per call. WASI has no threads, so processes give parallelism. A new version applies at the next call, and old processes retire after a grace period.
  • Attached by default. A process does not outlive its caller by accident. A long-running process must ask for detach.
  • Services in one file. Only the core starts and restarts them, so upgrades and singletons need no logic in plugins.
  • Files to inspect, calls to act. This keeps the core API small, and the agent's file tools are enough to explore the world.
  • The core accepts only .wasm files. It knows no source language. cargo is one way to get a .wasm file.
  • The world folder is the source of truth. Copy a plugin folder into another world, and it works there.
  • The agent is a normal plugin. It can read and upgrade its own source, and change how it does inference.

Interfaces

  • Generated imports and interface checks, without version numbers. A model that translates a description by hand makes errors. A generator does not. The check catches the most common error in a world that changes itself: a change to one plugin breaks another one.
  • A small schema subset. With Draft 7 and a fixed subset, our own check can decide every case. It never has to guess.
  • Docs are part of the interface. Other agents work from them, so the SDK requires a doc comment on each method, type, and field.
  • The check covers calls, not state. Old and new versions share /state. A plugin keeps its state format compatible, or migrates it in rpc.retired.

Minimal on purpose

The core does only what a plugin cannot do for itself. Everything else can be a plugin, or a change that the agent makes to itself.

  • No permissions, no limits, no hiding. Any process can call, upgrade, or stop any plugin, read and write every file, and use the network. The checks protect against mistakes, not against plugins. Locks are advisory.
  • Locks are in the core. A plugin cannot see that another process died, so it cannot release that process's lock. The core can.
  • Our own VFS, in its own crate. It works on Linux and macOS, it makes /proc live, and plugin-served files need it later. Mounts now, overlays later.
  • TCP endpoints for now. They need no host code. VFS pipes can replace them later behind the same SDK API.
  • Simple scheduling for now. An epoch tick every 10 milliseconds keeps the core responsive and makes kills reliable.
  • The agent is dumb. It has a short prompt, a few tools, an append-only history that it sends in full, and no compaction. When the context is too large, it stops sending the oldest turns, so it never gets stuck.

Small choices

  • Restart is a clean stop supervised by #loop. core.restart requires the host startup marker SAUSAGE_LOOP=1, which the loop exports for its invocation. Without a supervisor, restarting would only stop the world. A single pending request answers first, allows 250 ms for delivery, then signals the existing shutdown path; it does not use process::exit or bypass detached-process cleanup, lock release, or journal draining. The executable returns dedicated restart code 75 after cleanup. #loop refreshes and starts again on 75 (and other nonzero exits); 0 stops it. core.exit is a compatibility alias with the same supervisor gate, response grace, and coalescing; new callers use core.restart. HARD RULE: the mediator and all agents NEVER call core.restart without the person's explicit approval each time. This approval rule also covers legacy core.exit; it must not bypass the gate.

  • YAML for configuration, JSON for data. A person edits configuration, so it needs comments. init writes commented templates instead of asking questions, so there is one way to configure a world.

  • Plain POSIX files. Programs like tempfile, git, and SQLite expect normal files. Atomic writes use a temporary file and rename.

  • Tests run under WASI. cargo test on the host cannot find failures that occur only in WASI, for example a missing WASI function or a panic at start. The test runner uses the same Wasmtime and the same mounts as the core.

  • The SDK is not published. cargo points sausage-plugin = "0.1" at its own copy, which it also shows in /plugins/cargo/sdk/.

  • Compiled code is a cache, not state. It lives outside the world, in one cache folder that all worlds share: wasmtime/ (owned by Wasmtime, whose cleanup deletes unknown entries), cargo-target/, and sdk/<hash>/. A path that depends only on the content of the SDK lets cargo reuse the compiled SDK in every world. Tests run in the release profile, so that they share compiled crates with the release build.

  • mold links host code where Nix provides it. On Linux, the dev shell, the Nix builds, and the packaged sausage (for the proc macros and build scripts of plugins) link with mold, much faster than GNU ld. Plugins for wasm32-wasip2 link with rust-lld. Without Nix, the default linker stays.

  • No LTO. Measured: fat LTO makes the binary 28 % smaller, but compiles plugins and answers calls at most 5–8 % faster, for 4 more minutes per release build. Thin LTO brings nothing.

  • Discovery has a deadline of 10 seconds. It is an internal call of the core, not a limit on plugins. Without it, one plugin that does not answer would block every activation.

  • A broken plugin blocks nobody. A plugin without a document is already broken, so the check cannot protect it. The graph shows it as "unavailable", and core.revert and force always work.

  • rpc.retired is best effort. It does not run if the host stops first. A persistent queue of retirements would be more core for a rare case, so a new version checks its own migration marker instead.