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.
From Parts to a Running Runtime
Section titled “From Parts to a Running Runtime”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:
#[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 = Schematicis what the catalog's owncheckandapplydispatch over. A record catalog names no state, because a fact has no behavior to dispatch.error = SchematicErrornames 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.
The Application: Your App's Identity Card
Section titled “The Application: Your App's Identity Card”#[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.
app.rs: Three Verbs and Nothing Else
Section titled “app.rs: Three Verbs and Nothing Else”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:
| verb | what it is | what it can refuse with |
|---|---|---|
builder | the declaration, with no environment in it | nothing; it is infallible |
launch | the live entry: this document, this configuration | LaunchError |
simulate | the same declaration on nothing but a seed | Incompatible |
builder
Section titled “builder”/// 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.
launch
Section titled “launch”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).
simulate
Section titled “simulate”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()}The Composition-Only Rule
Section titled “The Composition-Only Rule”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.
The Shell, the App, and the Api
Section titled “The Shell, the App, and the Api”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 decides | the app owns | the api declares |
|---|---|---|
| where configuration comes from, and its fallbacks | Config::from_env and the typed refusal naming each variable | — |
| which document is open, and when to save it | the document format, Document::open, and the storage it designates | — |
| installing a tracing subscriber | instrumenting its own entry verbs | neither; it never names tracing |
| how an error is rendered, and exactly once | what the error is | the refusals a route can answer with |
| — | State, the catalogs, the Application impl, and the three verbs | the 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 itThe 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.
Handles, and How a Runtime Ends
Section titled “Handles, and How a Runtime Ends”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>isSendbut notSync. It moves between tasks; it is not shared between them.Journal<A>isSend,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 cheapSend + Synchandle 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().
Under the Hood: What runtime() Builds
Section titled “Under the Hood: What runtime() Builds”Four constructions, in order:
- The shared reality. Your origin state is wrapped into
TrackedState: the state paired with itsPositionbehind anRwLock, an empty recent-history tail, and twowatchchannels —appliedandstored— that broadcast progress (chapter 5 and chapter 8). - 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
Journalhandle you'll ever hold. The Receiver side exists exactly once — - — 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. - 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.
Test Your Knowledge
Section titled “Test Your Knowledge”
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?
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.
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?
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.