Dispatch
How a surface asks for something, in Rust.
The rule — every surface reaches behaviour through one entry point, and no surface orchestrates the core directly — is in the spec’s component-model. This is how it is built.
The three types
Section titled “The three types”pub enum Command { Version } // what was asked forpub struct Ctx { dry_run: bool, runner: Arc<dyn Runner> } // everything else it needspub enum Outcome { Version(VersionReport) } // what came back
pub async fn dispatch(command: Command, ctx: &Ctx) -> Result<Outcome, Problem>;clap parses into a Command. A keypress will build a Command. An HTTP route
will build a Command. None of them can do anything else, because there is
nothing else public to call.
Command and Outcome are deliberately exhaustive
Section titled “Command and Outcome are deliberately exhaustive”Neither is #[non_exhaustive], which is unusual for a library type and is the
point. The surfaces ship in the same binary as the core, so adding a command
should stop the build until every surface has decided what to do with it. A
wildcard arm would let a new command render as nothing at all, which is the exact
failure the single entry point exists to prevent.
If the core ever ships to a consumer outside this workspace, revisit this.
dry_run lives on the context
Section titled “dry_run lives on the context”Not on each command, and not as a parallel code path. A rehearsal and a real run differ in one field, so there is no second implementation to fall out of step with the first — the golden tests that cover a rehearsal are covering the real thing too.
let ctx = Ctx::new(Arc::new(Local)).rehearsing();Why dispatch is async when nothing in it blocks yet
Section titled “Why dispatch is async when nothing in it blocks yet”Because the first command it carries out already reaches a port. Command::Version
asks the engine for its version through Runner, which is genuinely async, so
the signature is honest today rather than aspirational.
That was a deliberate choice of first command. A version report that only read
constants would have needed a fake .await to satisfy clippy::unused_async,
and a fake await is a lie that survives into every future reader’s mental model.
Reaching a port instead means the whole spine — command, context, port, outcome,
envelope — is exercised by tests from the first commit.
The spine, end to end
Section titled “The spine, end to end”let ctx = Ctx::new(Arc::new(Local));let outcome = dispatch(Command::Version, &ctx).await?;println!("{}", serde_json::to_string(&outcome.envelope())?);Outcome::envelope wraps it with api_version and a kind, so machine-readable
output is the same value a person sees rather than a second rendering of it.
Outcome implements Serialize by hand — it forwards to the payload rather than
tagging itself, because the envelope already carries kind and a serde tag would
put the discriminant in twice.
Lifecycle: one function, four commands
Section titled “Lifecycle: one function, four commands”up, down, restart and pull all reach the same lifecycle function with a
different Action. Resolve the manifest, resolve the forms, materialise the
stack, build the command, run it — and a rehearsal returns after the build and
before the run, so what it reports is the command that would run rather than an
approximation of it.
A preview is that pipeline stopped after its second step: resolve the manifest, resolve the forms, answer. It shares those two steps with the lifecycle path rather than repeating them, so a preview and the command it precedes cannot disagree about which services a form holds or why one was left out.
ps and logs are deliberately not here. They are reads, and reads go
through the Engine API rather than through Compose — polling a subprocess once a
second across nineteen services would be both wasteful and visibly jittery.
Action therefore covers only up, down, stop, restart, pull and
config, which is exactly the split the architecture draws.
Errors come back as values
Section titled “Errors come back as values”dispatch returns Result<Outcome, Problem>, never a formatted string. The core
cannot print, so a surface receives the parts and decides how to show them —
colour and wrapping in the terminal, an object over HTTP. See
error-model.md.
Testing it
Section titled “Testing it”A fake Runner and no daemon:
struct Scripted(Result<Output, Failure>);
#[async_trait]impl Runner for Scripted { async fn run(&self, _argv: &[String]) -> Result<Output, Failure> { … }}
let ctx = Ctx::new(Arc::new(Scripted(Ok(spoke("v2.32.1")))));Four cases are covered for Version alone: the engine answers, the engine is
absent, the engine runs and fails, the engine will not start. All four report the
version — an operator asking what is in play is usually doing it because
something is broken, so it must answer when the engine is down.
Related
Section titled “Related”- module-layout.md · ports-and-adapters.md
- error-model.md
- surface-parity.md — which surfaces reach which of those commands, and what is missing
This page lives in another repository Rendered from lemonfiber/lemonfiber at a2ac9bf, 2026-09-09. Read the source of this page