Resolving a link is only half the job. Once Iris has turned a URL into an intent, that intent still has to reach the one view that will act on it, a view that has usually not mounted yet. Iris carries it as a baton: a one-shot hand-off that delivers exactly once, to exactly one consumer, at the moment the view mounts. Nothing is parked in shared state, and nothing can be consumed twice.
A resolved link has a destination view that must consume its payload. But that view is frequently built after the link is handled, so the two ends do not line up in time. The obvious fix, leaving the payload on a shared observable object until the view reads it, leaks: the payload outlives its use, a second view can read it, and a rapid second link can be consumed by the wrong screen.
Iris models the payload as a Baton, an event rather than a value, and delivers it through a one-shot Handoff that works whichever end arrives first, the link or the view. The hand-offs live in a HandoffRegistry keyed by route, which takes the newest link for a route and auto-removes the entry the instant it is delivered. The result is a single delivery, latest-wins, with no lingering state to read again or clear by hand.
Resolving a link is only half the journey. Iris turns a URL into an intent against the current app state; the state-aware resolver is that first half. But an intent is not a screen. It still has to reach the one view that consumes it, and that view is frequently not on screen yet: the link pushed the route that will build it, and the view mounts a moment later, on the next turn of the run loop.
So there is a gap. On one side, a payload that exists now. On the other, a consumer that does not exist yet. The tempting place to bridge it is somewhere both sides can see: a shared, observable object carrying the pending intent. The producer writes it; the view reads it when it appears. This works right up until it does not.
Shared mutable state is the wrong shape for a one-time event, and it fails in ordinary ways. The payload outlives its use: after the view reads it, it just sits there. A second view, or the same view reappearing later, reads it a second time. Two links in quick succession and the second overwrites the first mid-flight, or the first is consumed by a view that was meant for the second. And someone has to remember to clear the slot afterwards, by hand, on every path. None of these is exotic. They are what you get when a value that should be handed over once is instead left lying around.
What the situation actually calls for is a hand-off: a value delivered once, to one consumer, and then gone. Iris names that value a baton and gives it a delivery mechanism built for exactly-once.
A Baton carries two things: the resolved intent, and a flow, a correlation context that lets a single navigation run be traced from end to end. The intent is what the view will act on; the flow is how the whole chain, from URL to arrival, stays identifiable as one run.
The load-bearing choice is its identity. A baton is equal only to itself. Two batons with the same intent and the same flow still compare unequal, because each one is stamped with a fresh identifier the moment it is made, and equality and hashing are by that identifier alone. A baton is a one-shot event reference, not a value you can reconstruct.
That is deliberate, and it matters downstream. Because a baton stands for this arrival rather than this payload, you cannot recreate it, deduplicate it by content, or treat two separate firings of the same link as the same thing. Each time a link fires it is its own baton, distinct from every other. The delivery machinery can then reason about single, identifiable events instead of a mutable field whose current contents are anyone’s guess.
A baton exists exactly as long as it takes to hand it over. It is not a value left on a shelf; it is one event, delivered once, then gone.
The delivery mechanism is a Handoff: a one-shot coordinator that delivers a single baton to exactly one consumer. One is created per incoming link, so each run is independent of the last.
The hard part is ordering, and a hand-off is built to make ordering irrelevant. The link and the view do not arrive in a fixed sequence. Sometimes the link resolves first, while the view that will consume it does not exist yet. Sometimes the view mounts first and has to wait for a link still in flight. A hand-off handles both from one place. If the producer gets there first, the baton is buffered until a consumer claims it. If the consumer gets there first, its claim suspends until the producer delivers. Either way, the consumer receives the baton exactly once, and neither end has to know which of them won the race.
One-shot is enforced, not merely intended. A baton is claimed once; after a hand-off has delivered, further deliveries are ignored and further claims return nothing. If the waiting consumer is cancelled, because the view was dismissed before any link arrived, the hand-off still closes out cleanly: it delivers nothing and can never later deliver to a claimant that has already gone away. There is exactly one delivery or none, never two.
A per-link hand-off needs a home that the producer and the consuming view can both find without passing it hand to hand through the view tree. Iris keeps them in a HandoffRegistry keyed by route. That keying separates a hand-off’s lifecycle from the route’s identity, which lets routes stay plain hashable enums rather than carrying a hand-off as an associated value and paying for custom equality.
Registration is latest-wins. Registering a link for a route installs a fresh hand-off and replaces any existing one, so a second link to the same route supersedes the first rather than queueing behind it. The newest link is the one that resolves.
The registry is observable, and that is what keeps an already-open screen honest. A view already mounted for a route reads its hand-off in its own body, so when a new link replaces the entry the view re-renders, picks up the new hand-off’s identity, and restarts its claim against it. Without that observation, a re-pushed link to a route that is already on screen would install a hand-off the view was never watching, and the link would arrive nowhere. The superseded hand-off’s claim ends without delivering, so latest-wins holds without a stray second navigation.
Auto-clean on delivery is the part that keeps the payload from lingering. The instant a hand-off is delivered, by any path (claimed directly, buffered then claimed, or cancelled), it removes itself from the registry. Consumers never manage entries by hand. The removal is identity-aware: it only clears the slot if it still points at the same hand-off, so a late clean-up from a superseded hand-off cannot evict the fresher replacement that took its place. The payload exists for exactly as long as it takes to deliver, then it is gone. There is nothing left to read a second time and nothing to clear.
A hand-off carries one link to one destination and hands it over once. That is the right shape when a payload has a single rightful consumer, which is the common case for navigation.
When a link should instead reach every screen currently listening, a live URL stream rather than a single delivery, Iris uses a Broadcaster. It wraps each parsed URL in a baton and multicasts it to all current subscribers, and a new subscriber replays the last baton by default so it is never born out of date. Same baton vocabulary, a different guarantee: the hand-off is exactly-once to one consumer, the broadcaster is latest-value to many. The hand-off is the one that promises a payload is consumed once and never leaks.
The whole point of the baton is that a resolved link never becomes ambient state. It is an event with an owner and a lifetime: created when a link resolves, delivered to the one view that mounts to consume it, and cleaned away the moment it lands. The map of how a link gets resolved in the first place, and where all of this sits in the wider design, lives on the deep-linking hub.