Skip to content

Assemble and Launch

You have a state and a vocabulary of entries; assembly turns them into a running application. This chapter is the three declarations that do it — the catalogs, the Application impl, one builder call — the three entry verbs that stand the result up, what runtime() constructs behind them, and the handle rule that decides whether your shutdown ever returns.

You have a state (chapter 1) and a vocabulary of entries (chapter 2). Assembly is where they become an application — and the entire job is three small declarations that fit on one screen.

The Catalogs: Your App's Table of Contents

Section titled “The Catalogs: Your App's Table of Contents”

Every transaction and record your app defines is listed, by hand, in one enum per kind:

app/src/transactions/mod.rs
#[harmos::transactions(state = Schematic, error = SchematicError)]
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
pub enum Transactions {
/// See [`symbol::PlaceSymbol`].
PlaceSymbol(symbol::PlaceSymbol),
/// See [`symbol::MoveSymbol`].
MoveSymbol(symbol::MoveSymbol),
/// See [`net::ConnectPins`].
ConnectPins(net::ConnectPins),
/// See [`net::RenameNet`].
RenameNet(net::RenameNet),
}
// app/src/records/mod.rs
#[harmos::records]
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)]
pub enum Records {
/// See [`ExportCompleted`].
ExportCompleted(ExportCompleted),
}

This looks like bookkeeping. It's actually the app's mutation language, readable in one place. Open transactions/mod.rs and you know every way the schematic can possibly change — no grep, no registry scan, no "what else hooks in here?" The macro wires the plumbing: decoding a stored entry by its DefinitionId lands on the right variant.

Two pieces of that attribute are the whole vocabulary:

  • state = Schematic is what the catalog's own check and apply dispatch over. A record catalog names no state, because a fact has no behavior to dispatch.
  • error = SchematicError names the one type every refusal in this application arrives as (chapter 2). Omit it and the macro generates the aggregate enum for you, with one variant per definition.

The variants carry no markers. Whether a change can be taken back was answered where the change is defined — inverse = … or irreversible, in its own attribute — and closing the catalog collects those answers into the inverse table undo re-derives through. That is chapter 7's subject.

app/src/app.rs
#[derive(Clone, Copy, Debug)]
pub struct SchematicEditor;
impl Application for SchematicEditor {
type State = Schematic;
type Transaction = Transactions;
type Record = Records;
type Streams = Streams;
type Resources = ();
}

One impl block declares what your application is: this state, changed by this language, narrated by these facts, publishing these streams, and standing on these resources. Each of the five names a folder-module type in this same crate (or (), or Never). Everything generic in harmos is generic over exactly this — Runtime<SchematicEditor> — so there is no <S, T, R> triple to thread through your code, and no two parts of your app can disagree about what the state type is.

Resources is the only one of the five that is not a catalog. It is the bag of long-lived things your jobs borrow rather than carry — a client, a pool, a piece of resolved configuration — named once here and passed once at boot.

There is no default, which is why the schematic editor writes () rather than leaving the line out: it borrows nothing, and it has said so. An application that does borrow something names its own type here and passes the value as the second argument to Runtime::builder. What that buys, and where an attempt reads it, is chapter 11's subject.

Nothing in Resources is state. The writer never sees it, replay never folds it, and a reboot re-declares it rather than recovering it. If its value is part of what the application is, it belongs in State and reaches a job through the journal.

schematic's app.rs names the runtime once — pub type Runtime = harmos::Runtime<SchematicEditor>; — so no other module threads a generic. Then it holds exactly three verbs, in this order, and nothing else:

verbwhat it iswhat it can refuse with
builderthe declaration, with no environment in itnothing; it is infallible
launchthe live entry: this document, this configurationLaunchError
simulatethe same declaration on nothing but a seedIncompatible
/// Declares the canonical editor: its catalogs, and nothing else.
///
/// Infallible and environment-free. It reads no variable, opens no file, and
/// chooses no storage, which is what lets [`launch`] and [`simulate`] — and a
/// memory-only teaching boot — all start from the same declaration.
pub fn builder(origin: impl Into<Origin>, resources: ()) -> Builder<SchematicEditor> {
harmos::Runtime::builder(origin, resources)
.register(Transactions::catalog())
.register(Records::catalog())
.register(StreamCatalog::catalog())
}

Three declarations and one verb: register. Transactions::catalog() is the registration list the macro built from the definitions, carrying with it the inverse table it collected from their own declarations. Records::catalog() registers the same way, with no inverse table to carry. The stream catalog — chapter 6's row definitions — registers through the same call. It is imported here as StreamCatalog only because app.rs also publishes pub type Streams = harmos::Streams<SchematicEditor>, and one file cannot hold both names; renaming the import is the smaller of the two moves. One call per catalog, and no second per-definition line anywhere, because listing every definition twice is what permits drift. storage is chapter 8's, and it is optional: Storage::new() attaches nothing.

That it takes no Result is the point rather than a convenience. builder reads no variable, opens no file, and connects to nothing, so there is nothing for it to refuse with — which is exactly what lets a live boot, a memory-only boot, and a simulation all start from the identical declaration instead of from three that have drifted apart. A caller adds the environment it needs. The walkthrough's memory-only session adds a stream recording and asks for a runtime:

/// The declaration, with the recording attached and no history on disk.
///
/// `builder` is the whole editor minus its environment, which is why a
/// memory-only session and a live one are one runtime with different storage
/// rather than two different modes.
async fn recording(origin: Schematic, config: &Config) -> Result<Runtime, LaunchError> {
Ok(app::builder(origin, ())
.storage(storage::recordings(config)?)
.runtime()
.await?)
}

Three things deserve attention. First, origin is positional and up front: the builder takes the state the runtime will stand on before it takes anything else. For a new document that's Schematic::default(); for an opened file, you decode the file first — plain code, before harmos is involved — and hand the result in. There is deliberately no runtime.load(...) afterwards; a runtime that exists but doesn't know its state yet is a bug-shaped object, so the API makes it unrepresentable.

Second, runtime() is async and returns something that has already recovered. It freezes the catalogs, loads and replays whatever storage designated (chapter 9), opens the writer, wires the stream windows, and starts the attached folds — and only then hands you a Runtime. The one thing it can refuse with is Incompatible: a stored history or stream catalog this build cannot honor. There is no readiness gate to poll, because there is no readiness question.

Third, the journal you get back here is memory-only. Nothing designates a recovery source, so no history is loaded and none is written; if the process exits, it's gone. (The stream recording beside it is a different fold, and chapter 6 owns it.) That is not a limitation — it's the correct default for tests, prototypes, and tools where the artifact you save (chapter 8) is the only durability you want. Persistence is an explicit, separate decision, and it is the same declaration with a different storage argument.

A live session is a document and a configuration, and both were resolved before the call. launch composes them and boots:

#[tracing::instrument(level = "debug", skip_all, fields(...))]
pub async fn launch(document: Document, config: &Config) -> Result<Runtime, LaunchError> {
let storage = storage::attach(&document, config)?;
Ok(builder(document.into_origin(), ())
.storage(storage)
.runtime()
.await?)
}

Read what it does not do. It does not decide which file is open, does not read an environment variable, does not install a logger, and does not decide when to save. Those are a shell's, and the split is the next section.

Note what is also not on it: a #[cfg(not(target_arch = "wasm32"))]. There is nothing to gate, because this file lives in a crate a plugin never compiles — what a plugin compiles is the api crate beside it. An application that runs a sidecar composes its attachment on this same line — .service_with(services::probe::NAME, supervision, sidecar), with a clone of the call handle already in the resources it passed — so boot hands back a runtime whose jobs reach those routes through their own Scope (chapter 12).

The third verb takes a seed instead of an environment and hands back a SimulatedRuntime, which is chapter 17's subject:

#[cfg(feature = "sim")]
pub fn simulate(origin: impl Into<Origin>, seed: u64) -> Result<Simulated, Incompatible> {
builder(origin, ()).entropy_seed(seed).simulate()
}

One rule keeps app.rs readable as the file it is meant to be: no line in it constructs a client, parses a value, opens a file, or connects to anything. Every line calls a module that does — storage::attach, Document::open, Resources::connect — so the file stays the shape of the application rather than a pile of its wiring. When a verb starts growing steps, the steps move out and the verb keeps calling them.

The modules that grew out of app.rs in the schematic editor are exactly that: wire.rs holds the storage codec, storage.rs composes the folds, document.rs opens the saved file, config.rs reads the process settings, services/autoroute.rs opens the one capability a guest is admitted against and holds its api payload to the transaction it decodes as, and error.rs names what each of them refuses with. An application that runs finite work adds jobs/, one file per job, and resources.rs beside it — minibench-app is that shape.

app.rs is the top of the app crate, and it has a crate below it as well as one above. Three roles, and the line between them is what each one is allowed to decide:

the shell decidesthe app ownsthe api declares
where configuration comes from, and its fallbacksConfig::from_env and the typed refusal naming each variable—
which document is open, and when to save itthe document format, Document::open, and the storage it designates—
installing a tracing subscriberinstrumenting its own entry verbsneither; it never names tracing
how an error is rendered, and exactly oncewhat the error isthe refusals a route can answer with
—State, the catalogs, the Application impl, and the three verbsthe routes, replies, settings and invoke payloads a plugin compiles

That is why the schematic example is four crates rather than one:

examples/minischematic/
├── api/ minischematic-api — the contract its plugins compile
├── app/ minischematic-app — what the editor is, and how it runs
├── cli/ minischematic-cli — the shell over it
└── plugin/ minischematic-plugin — a guest beside it

The app/api line is the one a compiler holds. minischematic-app links a storage adapter and a component runtime and could never be built for wasm32; minischematic-api is built for wasm32 on every mise run check:wasm, which is what lets minischematic-plugin be written against it at all. Put the contract inside the application crate and it survives only while everybody remembers a #[cfg(target_arch = …)] — and the moment one is forgotten, the guest build is what breaks. Split them and there is nothing to remember: neither crate carries a single target gate.

The domain model is never the api, and that is the point of the seam rather than an accident of it. minischematic-api's autoroute::RenameNet is what an autorouter sends; transactions::net::RenameNet is what the editor does. They may share a shape today and diverge tomorrow, and services/autoroute.rs is where that is absorbed — and where a test holds the two to each other on the wire.

A second shell — a desktop window instead of a transcript — is a new crate beside the CLI and changes nothing in app.rs, and nothing at all in the api.

These declarations — catalogs, Application, and the three verbs — live in one app.rs. That file is your application's architecture, and it fits on one screen.

The Runtime is the composition: the journal, the streams beside it, and the storage declaration. What you pass around is not the runtime but a Journal<A> handle, and the difference is visible in the traits:

  • Runtime<A> is Send but not Sync. It moves between tasks; it is not shared between them.
  • Journal<A> is Send, Sync, and cheap to clone. Clone it per connection, per task, per guest, or per sidecar. journal.as_principal(alice) derives a handle whose entries are witnessed as that principal's (chapter 4).
  • Streams<A> has the same cheap Send + Sync handle shape for publish, query, watch, and demand. Chapter 6 gives those verbs their own stop.

Shutdown follows from the same shape. runtime.stop().await consumes the runtime, drops the journal it owns, and waits: the writer finishes what it admitted, the attached folds drain the tail, and the position they all reached comes back. But dropping the runtime's own handle only closes the channel if it was the last one — so drop your Journal clones before you call stop(), or it waits for a sender that nobody is going to release.

That is the whole shape of a session, and schematic's persistence pass is it verbatim — derive the handle, edit through it, read what the session built, release the handle, and only then stop:

let runtime = app::launch(document.clone(), config).await?;
let handle = runtime.journal.as_principal(alice());
let edits = edit(&handle).await;
let lost = whole(&runtime).await;
drop(handle);
let settled = runtime.stop().await.expect("runtime capture drained");
println!(" the process ends at {settled} without a save\n");

settled is the position everything reached.

Framework hosts keep the same boundary. The project runbook at .codex/skills/harmos-tauri/SKILL.md reduces it to one line: handles are the request-path currency; the runtime is boot/stop capability kept for the exit path. Manage Journal<A> and Streams<A> clones in commands, keep Runtime<A> for shutdown, and drop the managed handles before stop().

Four constructions, in order:

  1. The shared reality. Your origin state is wrapped into TrackedState: the state paired with its Position behind an RwLock, an empty recent-history tail, and two watch channels — applied and stored — that broadcast progress (chapter 5 and chapter 8).
  2. One channel, and its shape is the whole concurrency policy. A bounded mpsc channel is created. The Sender side is cloneable and lives inside every Journal handle you'll ever hold. The Receiver side exists exactly once —
  3. — inside the writer task, which runtime() spawns. The writer loops: take the next request from the channel, run the pipeline (chapter 4), update the shared reality. Because there is only one Receiver, there is only one mutator — single-writer isn't a rule anyone must remember; it's the type system's fact. No mutex on the write path, no lock ordering, no "who's allowed to call this" discipline. The channel being bounded means backpressure is built in: if commits arrive faster than they apply, senders queue instead of memory growing without bound.
  4. The stream windows. Boot freezes one ring per declared row type and wires them to the journal's applied frontier before attached recordings start. No recorder can miss a first row in an assembly gap, and no row enters the writer channel — its no-backpressure contract is why Streams is a separate handle.

One more quiet detail: in memory-only mode, the stored channel simply tracks applied. It exists either way — so code you write today against wait_until_stored (chapter 8) runs unchanged the day you attach real storage.

1. A teammate finds the builder annoying: “just let me call runtime() empty and call runtime.load(state) once my file is parsed.” Argue the design back — what class of bug does mandatory origin delete from existence?

It deletes the half-initialized window. Between runtime() and a hypothetical load(), the runtime exists: handles are cloneable, commits are sendable, watchers are attachable — all against a state that isn't the real one. Every consumer in the codebase would have to handle "up, but not loaded yet", a phantom lifecycle state that exists only because the API allowed it.

Deeper: origin is the zero point of the recovery equation, state = origin + entries since. Positions are measured from it. A swappable origin makes every position already issued meaningless.

The pattern is worth naming, because it recurs: don't validate the bad state, make it unconstructable.

2. The same teammate wraps state access so background threads can “fix up state directly with a Mutex when the writer's busy.” Name the two distinct disasters this creates — one visible immediately, one that detonates at the next recovery.

Immediate: interleaved writes produce states that correspond to no position. The At<S> promise — "this value is history up to N" — becomes a lie, and an edit interleaving with apply can produce a state that no sequence of entries could ever have produced.

Delayed: replay is perfectly deterministic, and that is exactly the problem. It rebuilds origin + journaled entries, and the Mutex edits were never entries. They aren't replayed wrongly; they aren't replayed at all. The customer's fixes evaporate the next time the file is opened.

An unjournaled mutation is not a race bug. It is a future data-loss bug that has already happened.

3. You write a new DeleteNet transaction file, implement the trait perfectly, and forget to add it to the Transactions enum. When and how do you find out, and why is that the right moment?

At compile time, at the commit call site. commit takes any value that is Into<A::Transaction>, and the conversion is generated per variant by #[harmos::transactions] — so a definition missing from the enum has no conversion into the catalog, and journal.commit(DeleteNet { .. }) does not build. The failure lands on your machine, before the code exists anywhere else.

Contrast a runtime registry that discovers definitions dynamically. There, the same omission compiles, ships, and fails at decode time in the field: the customer's file contains "net.delete", the registry has no entry for it, and their machine refuses their document.

The enum moves the failure from their machine to your compiler. Registration isn't a runtime activity; it's a fact the type system holds.

Stokker Technologies markDesigned and built by Stokker Technologies