How do you keep URLs short and readable when the right destination depends on current state? Iris gives every destination one URL and lets a frozen snapshot of the app’s state decide how to arrive. The grammar stays clean. The same short URL means the right thing from anywhere.
In a good URL scheme, every destination has one clean name. But the right way to arrive often depends on state: settings as a sheet from root, or a push from inside a thread.
Baking the difference into the URL makes the grammar explode. Iris keeps one URL per destination and lets a frozen snapshot (top of stack, depth, current sheet) decide the outcome at arrival time. This study shows the same URL resolving two ways, explains the snapshot, and is honest about the price: the URL no longer tells you everything on its own, and state-aware destinations cost more tests.
“Open the settings screen” is a destination, and botmessages://settings is its name. But the right way to arrive depends on context. From the root it should open as a sheet. From inside a thread it should push. Same destination, two correct outcomes. The user never asked which.
Conventional fixes leak. Either mint separate URL forms for each arrival, or bolt on parameters like ?presentation=sheet. Either way the URL stops being a pure destination and becomes instructions plus destination. Callers (tests, agents, Shortcuts) must now know current state before firing. That defeats the purpose of a URL.
This series already counted the external cost in the surface-area piece. This one examines the internal decision. If URLs are the input language for visible state, the resolver must be allowed to consult state at arrival time. Iris does so cleanly with a frozen snapshot.
Three destinations resolve by state, with six possible outcomes. All settled by one snapshot. That is the entire state-aware footprint of the demo app (Iris’s consumer). It covers every place where one URL legitimately means two things.
botmessages://settings resolves as a settings sheet at the conversations root, and as a push inside a thread. One thing in the snapshot decides: what sits on top of the navigation stack.botmessages://search?q=delivery becomes a search scoped to the conversation the user is reading, and a global search from anywhere else. The same thing decides.botmessages://attachment/<id> opens a full attachment detail screen when the app is shallow, and a Quick Look overlay when the user is deep in a stack or already has a sheet up. Two things decide: how deep the stack is, and whether a sheet is present.A fourth destination is state-aware only as a fallback. Image attachments expand the same way wherever the URL lands; everything else falls through to the attachment destination’s shallow-versus-deep split. Seven outcomes in total, and not one of them visible in the grammar.
The pair of recordings below shows the settings case live: the same URL fired twice, once from the conversations root and once from inside a thread.
From the conversations root
From inside a thread
botmessages://settings, fired from two starting points. A sheet over the conversations list on the left, a push onto the thread’s stack on the right.When a URL arrives, the coordinator takes one snapshot: what is on top of the stack, how deep it is, and which sheet (if any) is showing. That snapshot is handed to the parser along with the URL and never changes.
Parse is a pure function. It reads nothing live, produces one intent, and has no side effects. A test can invent any snapshot it wants and assert the result without the app running. Because the snapshot is frozen, the app cannot change underneath the decision.
Building the URL back goes the other way, and it never depends on state. Every destination maps to exactly one URL, however parse arrived at it. There is no sheet-specific settings URL and no push-specific one; ask Iris for the settings URL and you get botmessages://settings, full stop. That asymmetry is the whole bargain.
The URL is the destination’s name; the snapshot is what fills in how we get there from here.
Nothing here is free. The grammar gets its brevity by moving the meaning somewhere else, and it is worth being precise about where the bill lands.
A URL no longer tells you what it does on its own. To answer “what will this open?” you need the URL plus the current navigation state. For botmessages://settings the answer is one of two, never harder, but it is two.
Tests grow with the outcomes. A destination that resolves one way takes one test. A state-aware one takes a test per outcome, at every snapshot that matters, so the count grows with the number of ways it can resolve.
A new state-aware decision is three coordinated edits: the new outcome in the resolver, the check that says the URL resolves at all, and the one URL form the destination maps back to. A destination that resolves one way needs only the last.
Stateless URLs are the boring kind, and most of Iris’s grammar stays boring on purpose: one URL, one destination, no surprises. Stateful URLs put context somewhere, and the only real question is where. Iris’s answer is a pure function reading three frozen facts. The grammar stays short, the parse stays testable, and the URL a user copies out of a screenshot is the same URL a test fires.
The snapshot settles how a URL arrives. What a URL is allowed to change once it has arrived (search queries, filters, scroll targets, expansion overlays) is its own closed set, and the next piece in this series names it from the consumer’s side: one apply step that taps, URLs, tests, and recordings all converge on. The full map lives on the deep-linking hub.