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.
Name-Derived Message Tags
Section titled “Name-Derived Message Tags”#[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 Shape | Protobuf Encoding |
|---|---|
bool, u32, u64, i32, i64 | bool, uint32, uint64, int32, int64 varints |
f32, f64 | float/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 T | Embedded 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.
Objective
Section titled “Objective”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 Divergence, Argued
Section titled “The Divergence, Argued”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.
Principles
Section titled “Principles”- 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.
- 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.
- 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.
- 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. - 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-unknownandwasm32-wasip2. tonic stays strictly on the process-wire side. This is a compile-checked invariant, not an intention — see Where the Declaration Lives. - 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.
- 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.
Vocabulary
Section titled “Vocabulary”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
codeand amessage. answer— the guest world's one reply-bearing export, which every route call to a guest crosses. Deliberately notserve: see The Carriers.
The Declaration Surface
Section titled “The Declaration Surface”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.
Where the Declaration Lives
Section titled “Where the Declaration Lives”The Route declaration surface moves into harmos-artifact. No new crate is
created. The argument is evidence, not preference:
| The question | The 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.
The Carriers
Section titled “The Carriers”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.
Bounds
Section titled “Bounds”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.
| Facility | Mapping | Mechanism |
|---|---|---|
| Sidecar | the caller-armed deadline, unchanged | dispatch(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 |
| Guest | an epoch deadline derived from the bound, plus fuel as a separate cap | Config::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
Section titled “Failure”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 happened | Sidecar | Guest |
|---|---|---|
| the tenant said no | Refused { code, message } | the refusal record, same two fields |
| the bound elapsed | Deadline — the caller refuses, the child keeps working | Deadline — the epoch fires and the instance is discarded |
| no tenant is live | Unavailable | Unavailable |
| the payload left the contract | Malformed | Malformed |
| the tenant died mid-call | the three-exits regime decides restart or deactivate | the 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.
The Caller's Seam
Section titled “The Caller's Seam”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.
Lowering Domain Verbs
Section titled “Lowering Domain Verbs”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.
Refinements From the Frozen Contract
Section titled “Refinements From the Frozen Contract”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.
What This Build Does Not Do
Section titled “What This Build Does Not Do”- A guest cannot call the host through a route. The direction is host to
tenant only. A guest reaches the host through the
capabilitiesinterface 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-asyncas 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.
Non-Goals
Section titled “Non-Goals”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).
Validation Bar
Section titled “Validation Bar”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.