Skip to content

The Route Surface

A route is how an application calls a tenant it does not host: a typed method a sidecar answers over gRPC, a typed method a guest answers inside a wasm instance, and — after this contract — the same declaration for both. This is the design of that surface: what a route is, which carrier each facility gives it, and which decisions are closed.

This is the frozen contract the build works against: the shape is settled here, the argument for it is recorded here, and every decision that moves while building is written into Refinements with the rule that forced it. The Validation Bar is what finishing means.

#[harmos::message] owns protobuf authoring for named, non-generic Rust structs. Authors need no numeric tags, #[prost] fields, or direct Prost dependency. The macro derives Default and Debug; authors may add Clone, PartialEq, and Serde derives when their other contracts need them. harmos_path = ::facade::__harmos resolves the derive through a facade's private dependency re-export. #[harmos::stream] alone imposes no message encoding on process-local rows; add #[harmos::message] to published rows. Configuration keeps its existing JSON/Serde contract and is not a route message.

Tags use FNV-1a-32 over the UTF-8 field identifier with any Rust r# prefix removed: start at 2166136261, XOR each byte, then multiply by 16777619 with 32-bit wrapping. Compute 1 + hash % 536869911; add 1000 if that result is at least 19000. This yields protobuf tags 1..=536870911 excluding 19000..=19999. For example, value is tag 39772037 and payload is 319782775. The algorithm is fixed protocol, not Rust's randomized hash implementation.

Reordering or adding an unrelated field never renumbers an existing field. A same-message collision is a compile error naming both fields; there is no probing or reassignment. Renaming changes wire identity. Collisions against removed fields or between independently evolved declarations cannot be detected from the current struct alone: review field history before publishing a schema change and retire/version the contract when names could reuse prior tags. Stream history does not retain a tag ledger. Hashed tags commonly occupy five bytes instead of the one-byte keys of low handwritten tags; protobuf byte buffers and packed numeric arrays retain their compact payload representation.

Rust ShapeProtobuf Encoding
bool, u32, u64, i32, i64bool, uint32, uint64, int32, int64 varints
f32, f64float/double fixed-width IEEE 754, including NaN and infinities
String, Vec<u8>UTF-8 string, raw bytes
Option<T>Optional supported scalar or nested message
Vec<T>Repeated supported scalar or nested message; numeric/bool scalars packed
Named message TEmbedded message stored directly, default when absent

Maps, enum declarations, borrowed fields, generics, type aliases for scalar shapes, and nested containers (including Option<Vec<u8>>) are outside this macro's supported authoring shapes. Wrap a shape in a named message. Foreign Prost payloads, including String and (), can still satisfy harmos::Message; the route macro does not synthesize implementations for foreign types.

Protobuf semantics remain intact: unknown tags are ignored, absent scalars use their defaults, absent Option fields are None, and absent repeated fields are empty. There is no required-presence validation: validate business-required values in the route. A rename usually decodes to defaults, not a decoding error. Malformed protobuf produces MessageError and the adapters' existing refusal.

Inert declarations now use HARMOS:ARTIFACT:V2. Native and guest admission reject V1 before execution with a rebuild diagnostic, because the old manual tags do not match these tags. The gRPC protobuf envelope and WIT remain unchanged. Rebuild dependent artifacts and update consumers together; no legacy payload bridge is provided.

Give a tenant one execution interface. Today an application calls a sidecar through Route and calls a guest through nothing at all — the guest world carries deliver, which hands an entry down and takes no reply, so there is no way for an application to ask a guest a question and be answered. Routes close that hole from the other side: one declaration, two carriers, and a tenant that can move between facilities without its callers being rewritten.

Nothing here is a service-discovery layer, and nothing here is a second wire.

The org's own instinct for "one interface, either side of the wire" is WIT, and the ecosystem has a project built to deliver exactly that. This contract declines it, and the evidence is why.

WIT is unchallenged for host to guest, and irrelevant to host to process. wasmtime::component::bindgen! over a WIT world is the mechanism for typed host-component calls, canonicalized in the Wasmtime book's plugin chapter and framed as the toolchain's centre in Bytecode Alliance's The Road to Component Model 1.0 (2026-06-08). No competing idiom exists. That settles the guest carrier and says nothing about the sidecar one.

wRPC is the only project that built the thing we are declining, and its flagship adopter walked it back. wRPC carries one WIT interface either in-process or over a wire, which is architecturally what a harmonized route wants. But wasmCloud v2.0 (2026-03-23) removed capability providers outright — the out-of-process, possibly-non-wasm tenant role that maps precisely onto a harmos sidecar — replacing them with in-process host plugins and making wire routing explicit opt-in rather than automatic. wasmCloud was wRPC's reference production adopter. Corroborating but weaker: notes from the 2026-04-15 community call report a maintainer steering new distributed-RPC work toward gRPC; that is a meeting-notes paraphrase rather than a transcript, and the v2.0 post carries the argument on its own. The protocol itself is still pre-0.1 — SPEC.md declares 0.0.1-draft.1 — and ADOPTERS.md lists two entries, one of which is the company behind wasmCloud. Adopting it would mean discarding a shipped tonic/prost pipeline for a draft protocol whose encoding is Component Model values, not protobuf: a replacement, not a layer.

gRPC does not go into a guest. It runs there only by tunnelling gRPC-over-HTTP/2 through wasi:http sockets — Fermyon and Akamai's wasi-grpc for Spin (2025-09-08) and wasmCloud's grpc-hello-world example. harmos guests never receive sockets by doctrine, so the pattern is unavailable here on purpose. The standards-track wasi-grpc proposal is Phase 1 and holds mostly templates.

What does port is the payload language. prost depends only on bytes and supports no_std; tonic with transport dropped and codegen kept builds for wasm32 as well. Both were verified building clean on wasm32-unknown-unknown and wasm32-wasip2 in the research pass behind this contract, reproducing what wasmCloud's example compiles today. The split has precedent in the wild — envoy-types and restate-types both publish protobuf types carrying no transport dependency — though no project was found combining that split with a WIT boundary. That combination is unprecedented, and this contract says so rather than claiming ecosystem cover it does not have.

Streaming into guests is not ready. WASI 0.3 shipped 2026-06-11 with stream<T>, future<T>, and async in the Canonical ABI, and Wasmtime 46 enables component-model-async by default. But Wasmtime's own Config::wasm_component_model_async documentation says support "is very incomplete", and the Component Model 1.0 article records roughly 3.5x overhead on synchronous call paths routed through the async task machinery, with the optimization deferred until after P3 ships. This is the contract's one standing watch-item, not its architecture.

  1. A route is a Rust type, and it has one declaration site. The type carries the method both ends speak, the payload, the reply, and the bound a call is held to. The calling site names it and the serving site names it, and there is no third place — no manifest, no string table, no schema catalog — for them to disagree. This is the sidecar facility's existing rule, kept exactly and extended to guests rather than reinvented beside them.
  2. Routes are independent calls. A route does not talk to another route, and nothing in the surface expresses ordering, sessions, or a conversation spanning two calls. Sequencing belongs to the host-side orchestrator that already owns it, where it is visible and testable; a tenant that needs two things done in order is called twice.
  3. The world is fixed, and that is the feature. The guest world gains one generic reply-bearing export and never another for a route. A route is a value crossing that export, not a WIT function, so declaring one is a source change in one crate rather than a world change requiring every guest and the host to be rebuilt in lockstep. Route evolution never moves the ABI version.
  4. Protobuf is the payload schema language at every tenant boundary. prost types inside list<u8> at the WIT boundary; prost frames on the gRPC wire. One schema language buys field-level evolution — a tenant adds a field and ships without the host moving — plus codegen in every language a tenant might be written in, and it is what lets a route type be carried between facilities unchanged.
  5. The declaration builds for wasm32; the transport does not follow it there. The Route trait, its bound, its refusal, and its prost derives must compile for wasm32-unknown-unknown and wasm32-wasip2. tonic stays strictly on the process-wire side. This is a compile-checked invariant, not an intention — see Where the Declaration Lives.
  6. One bound is declared and mapped, never averaged. A route declares one bound. Each facility maps it to the mechanism it actually has. The mapping is written down and the numbers are never claimed to be the same number — see Bounds.
  7. Guest routes are synchronous request and reply. Payload in, reply or refusal out, one call at a time. No streaming into or out of a guest. Pushes and publications stay sidecar-wire-only, where they already work.
  • Route — one named operation: its method, its protobuf payload, its protobuf reply, and the bound one call of it is held to. Already the sidecar's vocabulary; now both facilities'.
  • Tenant — a loaded artifact that answers routes, whichever facility hosts it. A sidecar and a guest are two tenant kinds, not two route kinds.
  • Facility — the carrier a tenant is reached through: the sidecar's gRPC-over-stdio wire, or the guest's WIT world.
  • Bound — the wall-clock budget a route declares. One declaration; a per-facility mapping.
  • Refusal — why one call was not answered, in a vocabulary both facilities speak: a code and a message.
  • answer — the guest world's one reply-bearing export, which every route call to a guest crosses. Deliberately not serve: see The Carriers.

A route is declared once and named by both ends, exactly as it is today:

#[harmos::message]
#[derive(Clone, PartialEq)]
pub struct StartRecording {
pub output: String,
}
impl Route for StartRecording {
type Request = Self;
const METHOD: &'static str = "recorder.start";
const BOUND: Duration = Duration::from_secs(2);
type Reply = Started;
}

The trait is the one already in the workspace, with DEADLINE renamed to BOUND because it is no longer only a deadline:

pub trait Route: Send + Sync + 'static {
type Request: Message + Send + Sync;
const METHOD: &'static str;
const BOUND: Duration;
type Reply: Message + Send;
}

Message is harmos::Message, owned by harmos-artifact. Its blanket implementation delegates encoding and decoding to Prost and maps decoding errors to MessageError. Payloads and transport frames remain protobuf.

The rename is not cosmetic. DEADLINE is an accurate name for the only facility that had routes, and a false one the moment a guest maps the same number onto fuel and an epoch. It costs a census update, an attribute argument that still spells itself deadline_ms, and the RouteDeclaration field the host already reads — all three move together or none do, and the Validation Bar requires the census move in the same commit.

The Route declaration surface moves into harmos-artifact. No new crate is created. The argument is evidence, not preference:

The questionThe answer, verified
Do both facilities already depend on it?Yes — harmos-sidecar/Cargo.toml and harmos-guest/Cargo.toml both name harmos-artifact.workspace = true
Is the wasm-side crate one of them?Yes — harmos-guest is the crate a guest author compiles for wasm32, and it already depends on it
Is it wasm-clean?Yes — its whole dependency list is serde, serde_json, thiserror, and cargo check -p harmos-artifact succeeds for both wasm32-unknown-unknown and wasm32-wasip2
Is it already the shared-declaration home?Yes — Registration, Registrar, decode_config, and encode_config are documented there as crossing "an ABI or IPC seam", which is both facilities in one sentence
Why can the trait not stay where it is?The trait itself is already clean — wire.rs imports only prost::Message and std::time::Duration. The problem is the crate: harmos-sidecar carries tonic, tonic-prost, tower, hyper-util, and tokio, so a guest-side crate cannot reach the trait without dragging all of it onto wasm32

A new lean crate would duplicate a home that already exists, already spans both facilities, and is already proven on the guest target. The one thing harmos-artifact gains is prost, whose only required dependency is bytes and which supports no_std — so the crate stays as wasm-clean after the move as before it, and the Validation Bar pins that as a checked invariant rather than an accident.

The reserved-namespace constant moves with the trait. Today Frame::RESERVED is "harmos." on Frame in crates/harmos-sidecar/src/wire.rs, and it is enforced in exactly two runtime places: an author-side assert! in harmos-sidecar/src/kit/routes.rs when a route is registered on Routes, and a caller-side refusal in harmos/src/work/sidecar/mod.rs when a reserved method is dispatched. Once the constant lives beside the trait, one spelling serves both facilities — which is what extending the rule to guests requires.

The guard is a runtime assert today, and that is a gap this contract names. A reserved method is caught when Routes::on runs or when a call is dispatched, not when the route is declared. Since routes are declared at compile time and lowered from authoring attributes, the attribute is the honest place to refuse — harmos-artifact's validate_namespace currently accepts harmos.anything, because nothing has ever asked it not to. Closing that is part of the build.

One declaration, two carriers, and neither carrier learns anything new about routes as they are added.

The guest carrier is one new export on the fixed world:

export answer: func(method: string, payload: list<u8>) -> result<list<u8>, refusal>;

It is shaped exactly like deliver and for the same stated reason. The world's own comment on deliver already says it: "Every guest exports this, whether or not it subscribes: a world is one shape. A guest whose declaration requests no delivery is handed none, so the honest body for one is a refusal saying so." A guest serving no routes answers the same honest refusal, in the same way the Autoroute guest example already answers deliver.

The error arm is refusal, and that is a deliberate break from the other exports. Today the world's exports all refuse with a bare string, while the refusal record — { code: string, message: string } — exists only on the imported capabilities interface. A bare string cannot carry the shared envelope this contract requires, because a caller that must distinguish a tenant's "no" from a malformed payload needs a code it can branch on rather than prose. So answer refuses with refusal, which is added to the world's use list rather than invented, and the existing exports keep their string arm untouched.

The export is answer, not serve, and the name is settled here rather than during the build. serve is already taken by the sidecar facility: harmos_sidecar::serve is the kit's entry function, and it is census-frozen. Naming the guest's route export serve as well would make one word mean the sidecar's serve loop and the guest's single export at once — the exact collision the evidence facility paid a rename to remove when it resolved three different things called "artifact". answer is free, and it is already this runtime's word for what a servant does: the Call trait documents Reply as "what the peer answers one call with", and a sidecar that misses its bound "did not answer inside the route's deadline". The caller calls; the tenant answers.

deliver is untouched. It stays push-shaped and reply-less, and collapsing it into answer is a recorded non-goal.

The sidecar carrier is unchanged. gRPC over stdio, the existing Frame envelope, the existing reserved methods. No wire change of any kind is part of this contract.

What both carriers share is that a route is opaque to them: a method string and a payload of bytes in, bytes or a refusal out. That is precisely why adding a route is not a carrier change — and it is the direct answer to the rejected alternative of WIT-typed per-route exports, under which every new route is a breaking world change and a host/guest lockstep release.

A route declares one BOUND, and it means one thing: the wall-clock budget a caller gives one call. Each facility maps it to what it actually has.

FacilityMappingMechanism
Sidecarthe caller-armed deadline, unchangeddispatch(R::METHOD, params, R::BOUND) at crates/harmos/src/work/sidecar/mod.rs, bounded through the work layer's clock seam so simulation's virtual clock applies for free
Guestan epoch deadline derived from the bound, plus fuel as a separate capConfig::epoch_interruption with a host-driven ticker, beside the existing Store::set_fuel

Fuel is not a clock, and this contract does not pretend it is. The guest host configures consume_fuel(true) and sets a fuel budget today, and fuel alone cannot bound a route: a guest suspended inside a host call burns no fuel and would run past any wall-clock budget untouched. Epoch interruption is therefore new work this contract requires, not an existing knob it reuses.

The two guest caps answer different questions and both are kept: fuel bounds computation — a guest that loops forever is stopped whether or not a clock is watching, deterministically, which is what simulation needs — and the epoch deadline bounds time, which is what the caller was promised. Neither replaces the other.

Fuel becomes per-call, and today it is not. Store::set_fuel is called exactly once, in Loaded::register, with a budget of 10_000_000, and it is never refilled — so a Running guest draws one lifetime budget down across initialize, every deliver, and every host capability call it makes. Under routes that is not a bound but a countdown to a guest that stops answering for reasons no caller asked about. A route call is bounded work with a declared budget, so the build refills fuel per call and a route's fuel exhaustion refuses that call rather than quietly ending the tenant.

No table converting fuel units to milliseconds appears here or anywhere else. Such a table would be a lie about a number that depends on the component, the host, and the machine.

Failure semantics stay per-facility under a shared refusal envelope. The envelope is what a caller reads; the regimes underneath it are what each facility already does, unchanged.

What happenedSidecarGuest
the tenant said noRefused { code, message }the refusal record, same two fields
the bound elapsedDeadline — the caller refuses, the child keeps workingDeadline — the epoch fires and the instance is discarded
no tenant is liveUnavailableUnavailable
the payload left the contractMalformedMalformed
the tenant died mid-callthe three-exits regime decides restart or deactivatethe trap discards the instance, at most once, no retry

A guest trap is not a retryable failure. A trapped instance is discarded and the call is refused; nothing re-runs it. A route that changed application state before trapping changed it once, and re-running the call could change it twice. At-most-once is the only honest semantic for a call whose side effects landed through the host, and it is stated here so no later build "improves" it into a retry.

Discarding the instance is new work, not existing behaviour. Today a trap surfaces as Error::Guest { message } — the same variant a fuel exhaustion and an instantiation failure produce, distinguishable only by prose — and because deliver takes &mut self, the caller still holds a live Running whose Store is poisoned, with nothing preventing a second call into it. The guest host is a plain library type rather than a supervised Resident, so there is no restart credit and no discard path to inherit. This contract requires that a trap be its own refusal and that the instance it poisoned become unreachable.

The sidecar's three exits are untouched. The two scopes must not be conflated: a call refuses four ways — the four SidecarError variants above — while a sidecar process ends three ways, through Fault, and those three keep their existing regimes exactly. A route refusal is a call outcome and never a lifecycle event; nothing in this contract lets a refused route spend a restart credit or deactivate a service.

One reconciliation is required and is named rather than discovered: SidecarError::Refused carries code: i64 while the WIT refusal carries code: string. The shared envelope must pick one spelling and map the other at its facility's edge; the build decides which, and records it in Refinements.

One host-side call seam serves both facilities, and it addresses a tenant explicitly. A caller names the tenant and the route; the host resolves which facility hosts that tenant and dispatches accordingly. Migrating a tenant from guest to sidecar, or back, is a change to how it is declared and installed — not a change to a single call site.

There is no route catalog and no service discovery. Routes need no registry because tenants already have one: an artifact is loaded, inspected, and registered before it runs, and that registration is where a tenant's identity and its facility are already known. A route registry would be a second index over the same fact, drifting from it, and answering a question — "who serves this method?" — that this surface deliberately does not ask. A route belongs to the tenant whose type declares it, and the caller knows which tenant it is calling.

That also keeps the reserved namespace enforceable. A route method is checked against harmos. at the one site that declares it; with a global namespace shared by every tenant, the same method name declared by two tenants would be a collision nobody could adjudicate.

Routes are the lowering target for application authoring attributes, not a surface application authors meet directly. A step, a sampler, or any later domain verb emits a route declaration under a reserved namespace; the author writes the verb and never sees a method string, a payload encoding, or the word "endpoint".

#[harmos::route] already exists, and this surface adds no new attribute — it widens the one there is. The attribute is live today for the sidecar facility: it sits over a route type's own impl block, takes id and an optional deadline_ms, and emits both halves onto that type — the transport-neutral impl Call a caller and a host name, and the impl Route the serve loop dispatches through — plus an inert declaration fragment the host reads without executing anything. What changes is that a guest servant may carry it too, and that the emitted declaration names a bound rather than only a deadline.

The sidecar facility's own contract once recorded that "route declaration earned no macro", because nothing consumed route declarations as values. That was true when it was written and has since stopped being true — the attribute exists precisely because inert declarations became something the host reads. This contract does not reopen that; it inherits the attribute and extends its reach.

Two existing rules carry over unchanged and are worth naming, because a lowered verb must satisfy them too. A route's request type must be declarable by the crate that declares the route — the impl Route is generated onto that type, so a payload borrowed from another crate earns the orphan rule rather than a silent second contract. And a route method is async, because the serve loop awaits it.

The reserved harmos. namespace refusal extends to guests, so a lowered verb's namespace cannot be shadowed by a hand-declared route on either side.

Products declare route instances. The mechanism is harmos's kit and stays there: an application-side protocol crate defining how routes work is a recorded non-goal.

Each decision that moves while building is recorded here as a bolded claim and the argument the contract's own rules force, in the form the sidecar and artifact facilities use. A decision that moves without an entry here is a decision that escaped review.

Stage 0 built the declaration surface: the trait, the namespace, the refusal envelope, and the byte scan that reads an embedded declaration. The carriers, the world's answer export, the guest's epoch and per-call fuel, and the caller's seam are later stages and are untouched here.

The shared refusal envelope carries code: i64, and the WIT refusal maps to it at the guest's edge. The contract named this reconciliation and deferred it; what decides it is the contract's own sentence that the sidecar carrier is unchanged and that "no wire change of any kind is part of this contract." The process wire already spells the code numerically — sint64 code on the refusal frame in crates/harmos-sidecar/proto/sidecar.proto, with the negative reserved codes -32601, -32602, and -32000 beside it — so a string-coded envelope would be a proto change, which is the one thing ruled out. The guest side pays the mapping instead, and pays it in a carrier that does not exist yet, which is the cheaper of the two edges by construction. The refusal record on the imported capabilities interface keeps its code: string spelling untouched: it is a capability's refusal, not a route's, and nothing in this contract widens it.

Route::DEADLINE is now Route::BOUND: the deferred rename landed atomically in stage 1. The contract required that the trait const, the deadline_ms spelling, and "the RouteDeclaration field the host already reads" — "all three move together or none do." Stage 0 could not carry the trait const alone: it is emitted by #[harmos::route], at crates/harmos-macros/src/artifact/sidecar.rs, which wrote const DEADLINE into every generated impl Route, and the macro crate was outside stage 0's edit — so stage 0 selected "none do" rather than a half-rename that would drift the census from the surface. Stage 1 lands the whole sweep in one commit: the trait const, the macro's emitted const, RouteDeclaration's field — renamed to bound_ms, with #[serde(rename = "deadline_ms")] on the Fragment::Route parse target so the embedded JSON fragment (what today's and older macros emit) keeps parsing under its original spelling — the host's dispatch(R::METHOD, params, R::BOUND), the kit and throughput fixtures, crates/harmos/tests/sidecar.rs, the census, and docs/guide/12-isolate-a-sidecar.md. The deadline_ms attribute argument keeps its name, exactly as the contract's Declaration Surface requires.

A fragment kind the reading facility does not own is skipped; a kind outside the V2 set is refused by name. Both scanners hard-refused every kind they did not interpret, so a survey fragment linked beside a valid sidecar identity made the whole executable uninspectable — the failure arriving as a serde "unknown variant" sentence about a declaration the reader never asked for. The contract's validation bar requires that "a component built before answer existed still loading", and harmonization puts more kinds in more binaries, not fewer, because one linked artifact carries a fragment from every crate linked into it. Blanket tolerance is the wrong repair: it swallows a mistyped kind silently. So the unified scan knows the closed V2 union — sidecar, guest, route, requires, stream, invoke, survey, subscribe, egress — skips what the reading facility does not own, and refuses anything outside it as a named UnknownKind. Genuinely new kinds ride the marker's own version, which is what the V2 in \0HARMOS:ARTIFACT:V2: is for; tolerance is for the other facility, never for the next release.

Fragment fields stay closed, and the guest scanner is tightened to match the sidecar's. Kind tolerance and field tolerance are separate axes, and the argument that opens one closes the other: there is no cross-facility case for an unknown field, only a mistyped one. A misspelled optional argument would deserialize to None and ship a capability quietly declaring no credential. Field-level evolution is a promise this contract makes about protobuf payloads at a tenant boundary, not about the inert JSON the host reads a binary with — which the wire's own vocabulary already separates, since static declarations "never cross this live transport."

A malformed route id refuses as a route. A route id is artifact-scope like the identity beside it, so it was checked by the same validator and refused with the same sentence: "{id} is not a portable Harmos artifact id" — pointing a reader at an artifact id that was well formed. Routes now carry InvalidRouteId and their own sentence. This is the same fault the kind-aware id validation fixed for stream ids, applied to the remaining kind that shared a diagnostic with the identity.

The declaration crate is checked on both guest targets by mise run check:wasm, wired into mise run check. The contract asks for the invariant "as a task the repository runs"; running it only on request would make it an intention again, which is the thing principle 5 refuses. wasm32-wasip2 joins wasm32-unknown-unknown in the toolchain's declared targets so a fresh checkout provisions both.

The world is declared in its two halves and composed back, because that is the only way an older guest still loads. The contract requires "a guest built against the world before answer existed still loading", and with one flat world that is impossible: Wasmtime's generated export indices resolve every export the named world declares and fail the whole lookup when one is missing — _component.get_export(None, "answer").ok_or_else(|| format_err!("no export answer found"))?, emitted at wasmtime-internal-wit-bindgen-47.0.3/src/lib.rs:656 and reached through GuestIndices::new. So wit/harmos.wit names lifecycle and answering, and world guest { include lifecycle; include answering; } is the one world every guest implements. The host resolves the lifecycle half always and the answering half only when a component carries it. A route call to a guest that carries neither is refused in the same envelope a routeless guest refuses one in, so a caller gets one answer to "does this tenant serve routes", never two. The claim that this is the same world is not asserted: a guest serving five routes and a guest serving none decode to worlds with the same digest.

The WIT refusal's code is read back as the number it was, and a code that is not a number keeps its own spelling instead of becoming one. Stage 0 settled that the envelope is i64 and the guest edge pays the mapping. The kit writes the code decimal, so a round trip is exact. A code some other toolchain spelled as a word cannot be parsed into the envelope, and coercing it to a number would invent a meaning the tenant never gave it — the reserved code for work that failed is taken instead and the tenant's own word is kept in the message. Nothing is silently reinterpreted, which is the whole point of a code a caller branches on.

A discarded instance refuses behind a flag rather than by consuming the guest. Both were open. deliver already takes &mut self, so a consuming call would force every caller to thread ownership through the outcome that almost never happens in order to express the outcome that almost never happens — and it would buy less: a call the type system makes unwriteable cannot be observed being refused, and "a second call is refused without executing" is exactly what the Validation Bar asks to see. The flag gives the same guarantee and a test that can watch it hold.

Fuel exhaustion refuses the call and discards the instance, because the Component Model runtime discards it. The contract asks that fuel exhaustion refuse that call "rather than quietly ending the tenant". The refusal half is honored exactly: fuel is refilled per call so no tenant is ended by a lifetime countdown, and Exhausted names fuel distinctly from Deadline and Trapped, so nothing is quiet about it. The surviving half is not available. Wasmtime marks the store on any trap — store.0.set_trapped() at wasmtime-47.0.3/src/runtime/func.rs:1478, guarding every later component call through may_enter, which is Ok(!self.trapped()) at src/runtime/component/concurrent_disabled.rs:182 and raises Trap::CannotEnterComponent. A fuel trap is a trap. Keeping the tenant answering would mean re-instantiating it, which is a lifecycle verb this contract does not ask for and the guest host — "a plain library type rather than a supervised Resident" — has no discard-and-restart path to inherit. The honest surface is therefore: the call is refused by the name of what ended it, and the next call is refused without executing. A build that wants the tenant to survive its own fuel must add a restart, and should say so.

The epoch's resolution is one tick, and that is time measured against time. An epoch increment is engine-wide, so a caller arming a one-shot increment for its own bound would shorten every other call in flight on the same engine. One clock per loader ticking at a fixed cadence is what removes that, and a bound is then spent in ticks rounded up to at least one. This is emphatically not the banned table: no number here is derived from a fuel unit, and fuel and the epoch still answer their two separate questions.

Simulation cannot drive a guest at all, so the sim item is recorded rather than claimed. The bar asks for a scripted guest trap producing the same trace under two seeds "with the bound taken through the virtual clock". No such path exists to be taken. SimulatedRuntime exposes sixteen script verbs and not one of them reaches a guest; its own runtime is private (sim/mod.rs:36) while Host::new requires a &Runtime<A> (guest.rs:120); every guest verb is async and the harness's executor is pub(crate) (sim/executor.rs:37); a guest is not an Assembly declaration, so nothing refolds one per boot the way Supervised's Clone exists to let it; trace() is built from journal entries, cursors, service attempts, and entropy draws, none of which a guest call touches; and a guest invoke commits outside every identified script verb, so a reboot() would refuse it as having "bypassed an identified script verb" (sim/mod.rs:809-818). Guest determinism is therefore proved at the reach the facility has: the same scripted life — answers, a refusal, a trap, and the refusal after it — is run twice and compared whole. Wiring the harness to guests is its own stage, and the five seams above are its inventory.

The two shared refusal codes are hoisted beside Refusal, and neither facility spells a number any more. The previous stage recorded the duplication as a required follow-up: Frame::UNKNOWN and Frame::UNREADABLE lived on the sidecar's Frame, which a guest-side crate cannot reach without dragging tonic onto wasm32, so crates/harmos-guest/src/kit/routes.rs named -32601 and -32602 itself. That is one fact in two crates, and exactly what principle 1 exists to prevent — a caller branching on one envelope must branch on one set of numbers. UNKNOWN and UNREADABLE are now pub consts beside Refusal in crates/harmos-artifact/src/route.rs, and both facilities hand them out from their own kit: the guest through kit::routes, the sidecar through its crate root beside Route, Refusal, and RESERVED. The associated consts on Frame are deleted rather than left aliasing the new owner — a second spelling kept alive is the duplication this entry closes — so the wire, both kits, and the runtime's supervisor all read the one declaration. The census carries the two names onto SIDECAR_KIT_CENSUS in the same commit.

The guest typestate is Active, and the sentences above that say Running are the contract's account of what it found. Running named a state by what it was doing while the word was already spent one import away — ServiceStatus::Running is a supervised child's state in the work layer — so one word meant a wasm instance and a process state at once. Active says what the state is and matches the admission flag the bridge already keeps. The prose in Bounds and Failure is left exactly as written: it is the frozen contract's record of the code it was arguing about, and rewriting a frozen argument to match the code it produced would erase the argument. The rename is carried in the census instead, where it now cannot move again unwatched: the host-side guest loader surface was the one facility the census never named, which is precisely why its vocabulary was the one that drifted.

Permissions are removed outright, and a declared edge is admitted by being declared. The application owner's directive is the argument: "remove all permissions for now. everything is allowed. we will integrate a firewall later if needed." Taken at its word, that deletes rather than defaults: the consent argument on #[harmos::route] and on all four capability attributes, the consent field on RouteDeclaration and CapabilityDeclaration, the Consents set Loaded::register took, and the four unconsented refusals the capability bridge raised. Nothing is left behind a flag or a default-permissive value, because a dormant gate is a design decision made quietly by whoever later flips it, and the firewall this leaves room for wants one admission slot of its own rather than a disabled one inherited from a mechanism it is not.

The line this draws is between permission and mechanism, and only the first side moves. Declarations themselves stay, because inspection and planning read them before any artifact code runs. The undeclared-edge refusal stays: a guest calling an edge it never declared is a wiring bug, and reporting it as one is not a permission check. Credential custody stays exactly as it was — a secret never reaches a tenant, egress stays host-performed, and a declaration names a credential by handle. The reserved harmos. namespace refusal, fuel, the epoch bound, and trap discard are all untouched: none of them ever asked who was allowed, only what was possible.

The embedded fragment stays tolerant of a consent field, and this is the one place tolerance beats strictness. The refinement above says fragment fields stay closed, and it stands: deny_unknown_fields is what stops a mistyped credentials from deserializing to None and shipping a capability quietly declaring no credential. But a fragment is read out of a binary somebody already built, and every artifact compiled before this change carries consent in its bytes. Refusing it would mean a host cannot inspect an artifact it is being asked to install — the exact capability the whole static-declaration surface exists to provide, and the same "an older artifact still loads" promise the world's two-halves split was built to keep. So both scanners name the retired field explicitly and read it into a _consent nobody consults. Naming it is what keeps this one field wide instead of a blanket loosening: a typo is still refused by name, the tolerance is greppable, and the day it can go the deletion is those four lines. The source attribute is not tolerant in the same way — consent = "…" in a #[harmos::route] or a capability attribute is now an unknown argument and fails the build, because source is rebuilt and bytes are not.

  • A guest cannot call the host through a route. The direction is host to tenant only. A guest reaches the host through the capabilities interface it already imports, and nothing here widens that.
  • Routes do not overlap at a guest. One call at a time per instance. A tenant needing concurrency arranges it behind the host-side orchestrator, not inside the instance.
  • A call is not cancelled at the tenant when its caller gives up. The bound refuses the caller; a sidecar keeps working and its answer is discarded. Only a guest is actually stopped, and only because the epoch discards the whole instance.
  • Fuel and the epoch deadline are not reconciled into one number, and no build should try. See Bounds.
  • Guest streaming is absent, deliberately, and is the contract's one watch- item. It is revisited when Wasmtime stops describing component-model-async as very incomplete and the synchronous-path overhead is optimized — not before.
  • No route is carried between two tenants. Routes are host to tenant; a tenant that needs another tenant's answer asks the host, which is the orchestrator's job.

WIT-typed per-route exports (every route a breaking world change, host and guest in lockstep); WIT over the process wire (wRPC: pre-0.1 draft, and its flagship adopter removed exactly this pattern in v2.0); gRPC into guests (only reachable by tunnelling through wasi:http sockets guests never receive); a route catalog, registry, or service-discovery layer (tenants are already registered); application-side protocol crates defining route mechanism (applications declare instances; the kit is harmos's); guest streaming and guest-initiated pushes (sidecar-wire-only, unchanged); and collapsing deliver into answer (deliver stays push-shaped and reply-less).

mise run check, mise run test, and mise run build:docs:release green, with the census updated in the same commit as the surface change.

The declaration surface, compile-checked: cargo check -p harmos-artifact succeeding for wasm32-unknown-unknown and wasm32-wasip2 as a task the repository runs, so the crate that holds the Route declaration cannot silently acquire a dependency that breaks the guest target; and harmos-sidecar continuing to carry tonic with no guest-side crate reaching it.

The fixed world, proved rather than asserted: the world's hash unchanged across the addition of a route, which is the whole claim principle 3 makes; a guest built against the world before answer existed still loading; and a guest declaring no routes answering the honest refusal on answer exactly as one declaring no subscription answers it on deliver.

Behaviour, each a named test: one route type called against a sidecar and against a guest with the same call site and the same reply; a tenant migrated from guest to sidecar with no call site edited; a route refusal arriving as the same envelope from both facilities; a guest route exceeding its bound stopped by the epoch rather than running to completion; a guest looping without host calls stopped by fuel; a guest trap discarding its instance and refusing without a second attempt; a sidecar route exceeding its bound refusing the caller while the three exits stay untouched; a reserved-namespace route refusing at its declaration site on both facilities; and a payload gaining a field being read by a host that does not know it.

In simulation: a scripted route refusal and a scripted guest trap producing the same whole harness trace under two identical seeds, with the bound taken through the virtual clock.

Stokker Technologies markDesigned and built by Stokker Technologies