Routing and Permission
July 16, 2026 ยท View on GitHub
An inbox is the destination; routing is how a message gets to one and who is allowed to
send it. A capsule names a service or a pid, not a queue, and the kernel resolves that name
to a destination inbox only after checking that the caller holds the capability the target
endpoint requires. This page documents the permission check, the destination resolution,
and the wake. The code is src/ipc/kernel_ipc.rs and the send syscalls under
src/syscall/microkernel/ipc/.
The permission check
Every named-service route is gated by kernel_check_ipc_permission
(src/ipc/kernel_ipc.rs:45), which is also the first thing the routing path does:
endpoint = lookup_service(target) else ENOENT
if not caps::has(caller_pid, endpoint.caps_required):
return EACCES
A service is registered in the service registry
with a caps_required mask, and a caller may only reach it if it holds those capabilities.
An unknown target is ENOENT; an unauthorized one is EACCES. The kernel does not deliver
first and check later, and it does not let a capsule reach a service by naming its inbox
directly, because the inbox name is derived from the endpoint by the kernel, not supplied by
the caller.
Destination resolution
kernel_route_ipc_corr (kernel_ipc.rs:57) runs the same check and then decides the
destination inbox from the endpoint's owner:
dest = if endpoint.pid == KERNEL_OWNER { target } // kernel reply endpoint
else { "proc.{endpoint.pid}" } // owning capsule inbox
msg = IpcMessage::new("proc.{caller_pid}", dest, data)
msg.correlation = correlation
try_enqueue_strict(dest, msg)
A capsule service routes into its owner's canonical proc.<pid> inbox; a kernel-owned
reply endpoint routes into the named endpoint inbox the kernel client drains. Either way,
the message envelope records proc.<caller_pid> as the sender, so the receiver learns who
sent it from the kernel's stamp rather than from a field the sender could forge; caller
attestation is the kernel's job, not the payload's. The optional correlation id is carried
through for request and reply matching.
The wake
A successful enqueue to a capsule-owned endpoint checks whether the destination is asleep
and wakes it (kernel_ipc.rs:83):
if endpoint.pid != KERNEL_OWNER and sched::is_sleeping(endpoint.pid):
sched::wake_process(endpoint.pid)
This is what pairs with the receiver's sleep loop: a receiver that found its
inbox empty went to sleep on a deadline, and the sender's wake makes the delivery prompt
rather than waiting for the receiver's next timed poll. The strict enqueue's three failure
modes map to errnos here, ESRCH for a missing or dead-owner destination and EAGAIN for a
full queue, so the sender learns the outcome.
The send syscalls
Capsules reach this routing through the send family under src/syscall/microkernel/ipc/,
which adds the user-boundary handling on top:
sys_ipc_send(ipc/send.rs:47) bounds the length againstMAX_MESSAGE_SIZEbefore allocating, validates and copies the payload out of user space with the usercopy checks, resolves the endpoint to a target name, verifies the caller satisfies the endpoint's capability, and routes. A reply to a service's own fixed reply endpoint is redirected to the original caller's private inbox via the pending reply table.sys_ipc_send_to_pid(ipc/send_to_pid.rs:44) delivers straight to a destination pid'sproc.<pid>inbox for a server replying to arecv_fromcaller, still stamping the sender in the envelope, then wakes a sleeping destination.
Both reject a zero or oversize length up front, both validate the user buffer before touching it, and both surface the strict-enqueue failure modes as errnos. The kernel copies the payload into a kernel buffer before it enters the message, so the sender cannot mutate a message the kernel has accepted.
Security analysis
Routing is where the reachability boundary is actually drawn: a capsule names a service or a pid, and routing turns that name into a destination inbox only after deciding the caller is allowed to reach it. Everything a capsule can do to another capsule passes through here. Three properties hold the boundary, plus one honest limit.
A name, never an address. A caller reaches another capsule only by naming a registered endpoint;
there is no path where a capsule supplies a raw inbox name or an address. resolve_send_target
(send.rs:90) turns the numeric endpoint argument into a registry lookup, and
kernel_route_ipc_corr (kernel_ipc.rs:69) then derives the destination inbox itself: a capsule
service routes to proc.<endpoint.pid>, the owner's canonical inbox, and a kernel reply endpoint routes
to its own named inbox. The destination string is computed from the endpoint record, not taken from the
request, so a capsule cannot address an inbox the registry did not hand it, and cannot reach a service
by naming its inbox directly. The reachability graph is exactly the set of registered endpoints the
caller is permitted to call.
Capability-gated before delivery, not after. The first thing the route does is
kernel_check_ipc_permission / the inline check in kernel_route_ipc_corr (kernel_ipc.rs:45,
kernel_ipc.rs:64): an unknown target is ENOENT, and a caller that does not hold the endpoint's
caps_required is EACCES, before any enqueue. The send syscall re-checks the same requirement through
caller_satisfies_endpoint (send_caps.rs:24), which treats caps_required == 0 as open to any sender
and otherwise requires the caller's permission bits to cover the mask. The kernel does not deliver first
and check later. Registering a name is separately gated from calling one: sys_service_register
(register.rs:34) demands the caller hold the register-service or admin right for an ordinary name
(auth/caller_has_register_right.rs:17) and refuses a reserved name outright (register.rs:59), so the
capability to advertise a service is not the capability to call it.
The sender identity is the kernel's stamp. The envelope's from is written as proc.<caller_pid>
by the route itself (kernel_ipc.rs:74), never supplied by the sender, so the receiver learns who
called it from the kernel's attestation rather than a forgeable field. A capsule cannot make a message
appear to come from another capsule.
The payload is copied out of the sender before it enters the message. Both send syscalls bound the
length against MAX_MESSAGE_SIZE up front (send.rs:55), validate the user buffer with the
usercopy checks (send.rs:58), and copy it into a kernel buffer
(send.rs:62) before building the message. There is no unchecked user pointer crossing the boundary,
and once the kernel has accepted the message the sender cannot mutate it, because the kernel holds its
own copy.
The honest boundary: routing authenticates who the caller is and enforces whether it may reach an endpoint, but it does not authenticate the contents of the payload beyond the envelope's integrity tag, and it does not interpret them. A caller that legitimately holds the capability to reach a service can send that service any bytes it likes within the size bound; whatever the receiving capsule does with a well-formed but hostile request is that capsule's own input-validation problem, not routing's.
Debugging routing
Every routing outcome is one of a small errno set, and the value tells you which stage refused the call.
A send that returns EINVAL (-22) was rejected before routing even ran, for a zero or oversize length
at the syscall's own bound (send.rs:55); EFAULT (-14) means the user buffer failed the usercopy
validate or copy (send.rs:58). Past those, the route's own returns are the interesting ones:
ENOENT (-2) means lookup_service found no endpoint for the target, so the name is wrong or the
service never registered; EACCES (-13) means the endpoint exists but the caller lacks its
caps_required, which is an authorisation problem, not a wiring one; ESRCH (-3) means the
destination inbox is missing or its owner is dead (the strict-enqueue MissingInbox/DeadOwner folded
together at kernel_ipc.rs:90); and EAGAIN (-11) means the destination queue is full. The
sys_ipc_send path also returns EPERM (-1) when caller_satisfies_endpoint refuses the caps
(send.rs:67), which is the send-syscall mirror of the route's EACCES, so both a -1 and a -13
point at the same missing capability depending on which check caught it first.
Registration failures are a separate set: sys_service_register returns EINVAL for a bad name length
or non-UTF-8 name, EPERM for a reserved name or a caller without the register right, EBUSY (-16)
when the name or port is already taken (RegError::Exists), and ENOMEM (-12) when the table is at
MAX_SERVICES = 256 (register.rs:62). This is how a rejected registration is told apart from a hung
call: a registration that never took shows up as one of these at the register syscall, while a hung call
is a send that returned 0 (routed fine) followed by a receiver that never gets the reply. For that
case the traces are the tool: the route prints [ROUTE] from= target= dest= wake= for the traced
destination pids (kernel_ipc.rs:29) with wake=1 when it woke a sleeping receiver, and the send path
prints [IPC-SEND] ... (send.rs:36). A hung mk_ipc_call (call/sys_ipc_call.rs:31) that sent
successfully but timed out on the reply is diagnosed by whether the route to the server showed
wake=1: if the server was woken but no reply came back, the stall is in the server, and if the reply
send was refused, the caller's own reply inbox never filled and the call times out at its
5000 ms default (call/sys_ipc_call.rs:56).
Source map
src/ipc/kernel_ipc.rs permission check, destination resolution, wake, errno mapping
src/syscall/microkernel/ipc/send.rs sys_ipc_send, target resolution, reply redirect
src/syscall/microkernel/ipc/send_to_pid.rs sys_ipc_send_to_pid
src/syscall/microkernel/ipc/send_caps.rs caller_satisfies_endpoint, the send-side caps check
src/syscall/microkernel/ipc/register.rs sys_service_register and its errno set
src/syscall/microkernel/ipc/call/sys_ipc_call.rs the call/reply/timeout wrapper
src/services/registry.rs register_endpoint, lookup_service, lookup_port, MAX_SERVICES
src/services/registry/auth/ caller_can_register and the register-right check
Every reference above is verified against those trees. The capabilities these checks read are defined in the capability model, the queues routing delivers into and the wake it triggers are on the inbox page, the message it builds and stamps is on the envelope page, and the user-buffer copy it performs is the usercopy boundary.