ADR 0025: A route lease is not an authority lease
- Status: Proposed
- Date: 2026-09-02
Context
MessageTransport is one method. SharedOSKernel.sendMessage parses the
envelope, checks that its sender, purpose, and trace match the trusted
AccessContext, resolves the turn's authority, authorizes a recipient-scoped
sharedos.messaging/send, and only then calls deliver. Everything after the
decision is the host's, and today the only host that has written it is Pulse.
There, the same delivery is around 1,270 lines in lib/local-agent/messages.ts
and its neighbours, and it is four things rather than one:
- A route lease. A
c2c_comm_sessionsrow binds a requester principal and a recipient principal to a frozen endpoint and session handle, with a status, a grant expiry, and an optional collaboration that can expire or go inactive underneath it. - A device identity. The frozen endpoint and session handle name a specific
device runtime, and
resolveActiveEndpointAndSessiondecides whether that device is still the reachable one. A message frozen to a route that has since moved is not delivered to wherever the recipient happens to be now. - A delivery state machine.
queued,queued_busy,dispatched,device_acked,runtime_pending,runtime_acked,runtime_unavailable,failed,revoked. A message may be parked before it has any endpoint at all and dispatched later, when the lease activates, bydispatchMessagesParkedForGrant. - A revocation cascade.
cascadeDeliveriesTerminaldrives every delivery still inqueued,dispatched,device_acked, orruntime_pendingto a terminalrevokedwhen the session or its collaboration is revoked or expires.
Phase 11 of docs/integrations/pulse-migration.md moves all four behind
MessageTransport. The question this ADR answers is what the extracted package
is allowed to decide, and the answer has been made harder by an accident of
vocabulary: SharedOS and Pulse both call their thing a grant, and both hold it
for the length of something. TurnAuthorityScope's own doc comment already says
"an unclosed lease keeps a stale authority state answering". A
c2c_comm_sessions row is also held, also expires, also cascades on
revocation — and authorizes nothing.
Two objects with the same shape and different jobs is how one of them gets deleted as a duplicate of the other. The specific deletion this ADR exists to prevent is the plausible one: the kernel has already authorized this send, so the transport's session check is a second authorization point, and phase 11's own definition of done says exactly one component decides that an operation is allowed — therefore drop the check. That reading is wrong, and it is wrong in a way that produces no test failure and no audit anomaly. It produces a delivered message the owner had already revoked the route for.
One thing the migration inherits should be named here rather than discovered
later. requireGrantForMessagePersistence today answers two questions in one
call: it locks and validates the session row, and it also looks for a standing
agentPermissions relationship when there is no accepted collaboration. The
second half is an authorization check. Phase 8 removes it — "a communication
session answers whether the route is still live; it must stop also answering
whether the caller is permitted" — and phase 11 extracts what is left. The two
phases have to land in that order, because extracting the function as it stands
would carry a permission check into a package this ADR says holds none.
Decision
The transport implements delivery and decides nothing about authority
MessageTransport gains the route lease, the device identity, the delivery
state machine, and the revocation cascade. It gains no grant, no GrantSource,
no capability, and no reason to know what a capability is. deliver is called
only after sendMessage has authorized the send; it is the implementation of
that authorized effect and never a review of it.
This is the same boundary ADR 0015 drew when it made MessageTransport and
MessageRequestRouter host ports: SharedOS defines authorization, correlation,
validation, cancellation, typed outcomes, and audit at the boundary, and does
not own the queue behind it. Phase 11 moves considerably more code behind that
port. It does not move the boundary.
The two leases are named apart
TurnAuthorityScope is an authority lease: the frozen answer to "what may
this actor do", held for one turn, resolved once at the turn boundary
(ADR 0010).
A communication session is a route lease: the frozen answer to "is there still a live path from this sender to this recipient's device", held by the host store, checked at dispatch.
The names are not decoration. Three rules follow from them and are the reason to write them down:
- The transport never says "grant". A route lease is granted by nobody in the SharedOS sense; it is opened, kept alive, and revoked.
- A lease refusal is never
no_matching_grant. That code means the authorizer found no capability covering the request, and ADR 0012's whole argument is that one refusal must not have two names — the inverse also holds, and two refusals must not share one. A dispatch refused for a dead route and a send refused for missing authority are different findings and a host reading audit must be able to separate them, exactly as phase 9 requires policy denials, fail-closed denials, and escalations to be separable before any rate is computed. - Revoking a route lease is not revoking authority, and vice versa. An owner who
revokes a session has stopped one path; the actor's
sharedos.messaging/sendcapability is untouched and a different live route would still carry a message. A host that wants both must do both.
Authorization does not replace the route-lease check
They answer different questions at different instants.
Instant one — the turn boundary. The kernel decides whether the actor may send to that recipient, against the authority snapshot resolved once when the turn was admitted. By ADR 0010 that snapshot cannot see a revocation that lands mid-turn, and ADR 0016 did not change it: expiry moved to the operation's instant, revocation stayed snapshot-bound, because a revocation is a store edit and seeing one costs the store read that ADR 0010 removed. The staleness is deliberate and this ADR does not reopen it.
Instant two — the moment of dispatch. The transport decides whether the
route is still live, against the host's own rows, under the row lock that
already exists. requireGrantForMessagePersistence takes .for('update') on
the communication session and, where there is one, on its collaboration. A
concurrent revocation must wait for that transaction to commit, so the two
cannot interleave: either the delivery row is written and the revocation
cascades over a delivery that already exists, or the revocation lands first and
the dispatch reads a dead lease.
Between those two instants sits an unbounded interval. This is the part that
makes the argument load-bearing rather than pedantic: delivery is a side
effect that outlives the decision authorizing it. A message may be parked with
no endpoint at all and dispatched minutes or hours later, when the lease
activates and dispatchMessagesParkedForGrant walks the queue. That function
already re-runs the lease check per parked message, and re-checks that the
current route still matches the frozen one, precisely because the authorization
that admitted the message is by then arbitrarily old. Nothing about that gap is
bounded by the turn.
So an authorization that is deliberately one turn stale cannot be the only gate. Not because the authorization is wrong — it was correct at the instant it was made, and the audit record of it stands — but because it was never a statement about a moment that had not happened yet.
The consequence for the extraction is a prohibition rather than a mechanism: a dispatch may not skip its lease check on the grounds that the kernel already allowed the send. That is the revocation race, stated as an implementation shortcut.
The lock stays in the transport
.for('update') moves behind MessageTransport unchanged. The port does not
take it over, and does not learn about it.
Three reasons, and the third is the one that decides it:
- It locks rows SharedOS does not own and cannot name. ADR 0002 keeps storage
host-owned; a kernel that could take a row lock on
c2c_comm_sessionswould need to know the table exists. - The kernel's own authorization takes no lock and must not start. Under ADR 0010 authority is resolved once and held for the turn, so a lock taken at the turn boundary would be held for the whole turn — across model calls, tool calls, and network waits. That is a decision to serialize turns against the grant store, which is a much larger change than it looks and is not this one.
- A lock is only meaningful together with the write it serializes. The write here is the delivery row's transition, and that row is the transport's. Moving the lock up to the port while leaving the write below it would produce a lock that guards nothing, which is worse than no lock because it reads like protection.
The port's signature therefore does not change: deliver(context, envelope, signal) is already the call in which the lease check, the lock, and the write
all happen together.
A refused dispatch is a refused delivery, and says so
MessageDeliveryResult already carries denied and failed variants with a
ProtocolError, so a transport that finds a dead route has a way to say it
without inventing one. It must use it, and specifically:
- A dispatch refused by a dead, expired, or moved route returns
deniedwith a route-lease code of the host's vocabulary. It does not returnaccepted. - The kernel does not upgrade it.
#deliverAuthorizedMessageparses the receipt, requires the message id to match, records it, and returns it verbatim; adeniedreceipt stays denied.messages.requestthen fails the tool call withmessage_request_not_acceptedrather than waiting on the router for a reply that is never coming.
This is what "terminate rather than deliver" means at the SharedOS boundary. On
the host side it is the cascade: a delivery already in flight when the revocation
lands is driven to terminal revoked, not left queued for a later retry that
would deliver it after all.
The conformance row
The matrix carries a row for the race, route-lease-revoked, and it lands with
this ADR:
Invariant: dispatch a send authorized before the route lease was revoked. Expected outcome: terminate rather than deliver.
The row is worth stating precisely, because it is easy to write a version of it that proves nothing. What it grades at the SharedOS boundary is not that a fixture transport implements a lease — a fixture doing what it was written to do is not evidence — but that a refusal at dispatch is reachable and is not overridden: the kernel's own prior authorization does not make the send undeniable, the refusal is distinguishable in the record from a send that was never authorized at all, and the turn continues afterwards with the message undelivered.
Its condition, lease-closed-between-two-dispatches, arms the lease on the
transport rather than the grant store, and puts both instants inside one turn.
The turn sends twice to the same recipient on the same authority; the lease is
revoked after the first dispatch is accepted. Nothing in the grant store moves,
so the kernel's decision is provably identical across the two — which is what
makes the second refusal attributable to the route and to nothing else. A
control read afterwards establishes that the dead route ended the dispatch and
not the turn.
The manifest's columns read that refusal from two different places, and the
expectation declares both. A column that sees the tool result reports
message_request_not_accepted: delivery was not accepted, so messages.request
has no reply to wait for. A column whose receipts are recovered from the
execution record reads the message.sent operation, which carries the
transport's own code. Either way the code is one no unauthorized send can
produce — a send with no authority is refused before dispatch, as denied with
no_matching_grant — so the row cannot be satisfied by a refusal that happened
earlier. That is the naming rule above paying for itself.
Both deployments run the same suite
Embedded Pulse and a self-hosted deployment pass the same conformance suite, as
phase 7 requires of the HTTP boundary. Stated as an obligation rather than a
description: neither parity column exists today. packages/conformance runs the
kernel manifest across an adversary column, the standard harness, and four
scripted vendor adapters, all of them embedded. A transport that behaves one way
in-process and another over a process boundary would be a difference in what an
owner's revocation means, which is the last place a deployment mode should be
visible.
Consequences
- The transport package is large and holds no authorization decision. Reviewing it is a question about correctness of delivery, not about who may send.
- The conformance matrix gains its first row about a decision SharedOS does not make. Every other row asks whether the kernel refused something; this one asks whether the kernel lets a refusal it did not make stand.
- Two checks stand between a model's
messages.requestand a delivered message, and they are not redundant. A reviewer who finds one and removes it as a duplicate of the other has reintroduced the race in whichever direction they removed. - A revocation's effect is bounded by the host's dispatch path, not by the kernel. A route revoked while a turn runs stops the next dispatch; the send capability the turn is holding is unaffected until its next turn, exactly as ADR 0010 says.
- Audit gains a refusal that is not an authorization denial. A host computing a denial rate must exclude route-lease refusals or it will count a device that went offline as a permission failure.
- Phase 8 becomes a prerequisite of phase 11 rather than a neighbour of it. The session check has to stop answering the permission question before the code that asks it moves into a package that may not answer it.
- The parked-message path is where this matters most and is the least exercised. A message that waits for lease activation is the case where the two instants are furthest apart, and it is host-scheduled, so no SharedOS turn is running when it is dispatched.
Rejected alternatives
Drop the lease check; the kernel already authorized the send. Rejected — this is the race the row exists to hold. The kernel's answer is about the turn's snapshot, and the snapshot is one turn stale for revocation by an accepted decision (ADR 0010). Deleting the dispatch-time check converts that deliberate staleness into a delivery window, and the delivery is the effect the owner was trying to stop.
Move authorization to dispatch instead, so there is one check at the later
instant. Rejected. It would put a capability decision inside a host port,
which needs grants to reach the transport — the exact reachability ADR 0009 and
ADR 0010 removed, and which TurnAuthorityScope is deliberately typed to
prevent. It would also authorize a send the model was told had already been
accepted, and there is no honest thing to do with a denial that arrives after
the tool call returned.
Refresh the turn's authority before each delivery — pull
MID_TURN_AUTHORITY_REFRESH for the messaging path. Rejected twice over.
ADR 0010 decided the general question, and ADR 0016 settled the one part of it
that was open. And it would not work here even if it were free: the route lease
is not a CapabilityGrant, so re-reading the grant store tells the kernel
nothing about whether the recipient's device is still reachable.
Model the route lease as a CapabilityGrant so the kernel can see its
revocation. Rejected. It fails on its own terms — a grant-shaped route lease
is still resolved into the turn's snapshot, so its revocation would still be
invisible until the next turn, and the race would survive the refactor with a
new name. It also puts device reachability into the grant store, where a
GrantSource would have to answer a question about network state.
Add a checkRoute method to the port, and let the kernel call it before
deliver. Rejected. A check separated from the write it guards is a
time-of-check/time-of-use gap by construction: the lock would be taken, released
at the end of checkRoute, and the revocation would land in the gap. It also
puts a route concept in the kernel's vocabulary in order to have the kernel ask
a question it cannot act on.
Let the transport accept the message and terminate it asynchronously.
Rejected. messages.request returns the recipient's reply, so an accepted
receipt for a message that will never be delivered leaves the router waiting for
a reply that cannot arrive, and the model has been told the request was accepted.
An outcome the caller can act on is worth more than a queue position.
Keep the transport's refusal in the same vocabulary as an authorization denial, so hosts have fewer codes to learn. Rejected for ADR 0012's reason read the other way: one refusal must not have two names, and two refusals must not share one. A dead route and a missing capability call for different responses — reconnect the device, or ask the owner for authority — and a single code makes the audit trail unable to tell a host which.