The ticket described tapping the link in the email and landing on someone else's order screen.
It took us most of a day to reproduce, because nothing had crashed and nothing had thrown. The app did exactly what we had told it to do.
Our deep-link handler was a chain of prefix checks: one long function, maybe forty branches, grown over two years by different people on the team. Each branch matched a path fragment, pulled whatever segments it needed, and pushed a screen. The last branch was a catch-all that tried to be helpful, so if nothing matched cleanly it sent the user somewhere reasonable.
That catch-all was the bug. A malformed link fell through several near-matches, hit the fallback, and the fallback reused an identifier it had parsed two branches earlier. The screen opened with a stale value. No error, no log line, just a wrong screen for a real user.
Once we went looking, the routing code had three more problems we had never named. It did not care what host the link came from, so a link from a domain we do not own opened the same internal screens. It did not care about scheme either, so an insecure link and a custom-scheme link were treated identically. And it forwarded every query parameter it found straight into the destination, including ones we had never designed for.
So we stopped treating routing as control flow and started treating it as a boundary that either accepts input or refuses it.
What we run now is a declared route table. Each route states its pattern, the type of every placeholder, and a constraint per placeholder. Each route also states which hosts it accepts and which schemes. Query parameters are allow-listed per route, and anything not listed is dropped rather than passed through. Validation happens before dispatch, so a route either produces a fully typed destination or produces a refusal.
The refusals are explicit and each one has a name: foreign host, insecure scheme, unknown scheme, form not accepted. We log the name. That single change turned "the link did something odd" into a line in a dashboard that tells you which policy rejected it and why.
And there is no fall-through. If nothing matches, the answer is no match, not "whatever matched last". A user landing on a safe default is a product decision we make deliberately in one place, not an accident of branch ordering.
We pulled the pattern out into an open-source library we call deeplinkmap, mostly so we would stop rewriting the same forty-branch function on each new mobile project.
The part that surprised us: once the table was declared as data, we could review it. A route table you can read in one sitting is an artifact a QA engineer can audit. A chain of prefix checks is not.
For those of you running deep links in production, the place where host and scheme policy actually gets enforced, in the app or at the link-generation layer, is worth naming out loud.