Wire services
How one plugin reaches another's behaviour, in process by type and across processes by key, method and schema.
Two rendezvous, one idea
A plugin may not depend on another plugin (ADR-0001). What it may do is look up a service: a live object its owner registered under a string key.
In process, the rendezvous is by type. Owner and consumer import one contract
crate and meet at the same TypeId — the lookup downcasts to the concrete
type both of them named:
pub fn service<T: Any + Send + Sync>(&self, key: &str) -> Option<Arc<T>>;
A TypeId cannot cross a process. So across one, a service is met by the same
trio the tool contract already rides — a string key, a method name, and a JSON
schema (ADR-0031). Across a process boundary the contract has to take a form
its consumers can read, and that form is a schema, not a type.
The one new trait
#[async_trait]
pub trait WireService: Send + Sync {
async fn call(&self, method: &str, params: Value) -> Result<Value, ServiceError>;
}
That is the whole of it, and it is the only trait this design mints. What a particular service means lives in its own api crate, never in the sdk, because the kernel keeps no feature nouns.
A method the service does not speak is refused with the set it does speak. A
ServiceError carries words and nothing to branch on: a consumer that needs
kinds is talking to a typed trait in an api crate, not to this face.
Crossing is the owner’s choice
A contribution carries both faces of one live object:
Contribution::Service {
key: "acme.linter".into(),
value: handle.clone(), // what host.service::<T>(key) downcasts to
wire: Some(adapter), // what a process's service/call reaches
}
Without a wire face, the service does not exist to a process at all. Registering one is a deliberate act, and the adapter that turns the typed handle into the wire face is mechanical — the typed trait is the source, the adapter is derived, and neither side hand-writes the contract twice.
An external service — one nothing knew about until its handshake answered —
enters the registry through HostApi::open_service, which builds both faces
from the one object. A consumer reaches it with
host.service::<ServiceHandle>(key) and calls it by method; N external
services are N of those, differing by the process behind them and never by
type. Both service_wire and open_service default to refusing in words, so
a host that keeps no services says so rather than failing quietly.
Declaring one from outside
An external plugin declares its services in the handshake:
{
"services": {
"acme.linter": {
"methods": {
"lint": { "type": "object", "properties": { "path": { "type": "string" } } }
}
}
}
}
The schema is for whoever writes the caller; nothing validates against it. The method names, though, are the host’s: a method the declaration never named is refused before it crosses.
A plugin that needs a service says so in requires:
requires: ["service:acme.linter"]
A missing requirement disables the plugin with a notice. It never crashes the host.
One method, both directions
{"jsonrpc":"2.0","id":7,"method":"service/call",
"params":{"key":"acme.linter","method":"lint","params":{"path":"src/lib.rs"}}}
The same line serves both ways. Host to process, it drives an external implementation of a service. Process to host, it lets an external consumer call anybody’s — the connection grows reverse requests for exactly this. External to external routes through the host: the registry is the router, and there are no process-to-process pipes.
Neither side reads params. It is the service’s own contract, and the bridge
is a pipe for it. A service with nothing to say answers null, which is an
answer; a service that could not answer fails the call.
Two external plugins can therefore pair on a service with no Rust written by anyone. And a Rust consumer of an external service programs against its api crate’s trait and cannot tell the implementation moved out of process.
Services carry no kernel authority
A service call is code-to-code between plugins the person installed. The permission gate is not involved, and the schema’s own words say a service call is the plugin’s own act. Nothing about a service can loosen what a tool may do.
The host’s own two doors
The bridge registers one reserved service, bingo.host, so a process can reach
the host through the same lane it reaches any service (ADR-0033). Its methods
are the only things a plugin process may ask of the host, and there are two of
them.
ask {call, question} → {answer} puts a question to the person.
{"key":"bingo.host","method":"ask",
"params":{"call":"<the callId from tool/call>","question":{ … }}}
The call is the whole of the grant. The bridge already tracks running calls
for progress and cancellation, and that liveness is what authorises the
question: an ended call is not there to ask on, and another connection’s call
is not this caller’s to reach. Both are refused in words. The question is the
sdk’s own InteractionKind and rides the same asking machinery an in-process
tool’s question does; there is no second question path. Nothing is minted for
it.
notice {level, message} → {} says one line for the person, under the
plugin’s own name, at any time. It is the one door scoped by nothing: it spends
nothing but a line. The level is the kernel’s own three, and an omitted one is
the quietest.
What a door is, and what it is not
The taxonomy behind this is worth stating, because it is what keeps the surface small. What is visible and attributable — spawning, posting — crosses as an ordinary plugin-owned service. What only observes rides the hook observation lane. What spends — money, the person’s attention, or their private data — crosses as an allowance: minted where a crossing begins, metered, and dead when the crossing ends. Nothing is ambient and nothing is renewed.
A grant is a spend, never a permission. No grant is Allow-shaped, and the
verdict plane is untouched by all of it. A future capability enters as one more
method on bingo.host plus one scoping fact, each behind its own decision
naming its scope and its accounting — and a door mints only where its scoping
fact exists nowhere else. What is not a method does not exist across the line.