carlos / platform Public

docs: §6 describes the matching rule it actually has

§6 still said "take the first path segment" and "a plain equality test against
the request's first segment". That was true before this branch normalised the
match and false after it — the only semantic change to request routing in the
whole feature, missing from the binding spec. Same drift class as the §7 wire
shape, reintroduced on the read side: a consumer reasoning about "//acct" or
"/a/../b" from the spec got the wrong answer.

§6 now gives the rule as it is. Step 3 takes the first segment in both
spellings; a new subsection states the raw/normalised pair, the tie-break, the
one-way invariant (the app an account was flipped away from must never answer
for that account), the bypass each spelling alone would leave, the cost of
taking both, and the three residue cases — the encoded dot-dot, the encoded
slash, and why "." and ".." can never be prefixes. The no-prefix-stripping
paragraph gains the sentence the odd shapes need: matching normalises,
proxying never does.

§2's "no normalisation step to get wrong" was about the STORED prefix, so it
was not wrong, but it read oddly beside a matcher that normalises. It now
carries a clause separating the two problems: one spelling on the write side,
several on the read side.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 83e3d0896508559485162a6041f417a49f09e4fe parent a22906d
1 file changed, +55 −8
  • docs/superpowers/specs/2026-09-02-path-based-routing-design.md +55 −8
diff --git a/docs/superpowers/specs/2026-09-02-path-based-routing-design.md b/docs/superpowers/specs/2026-09-02-path-based-routing-design.md
index c6973294e..3c038b4f3 100644
--- a/docs/superpowers/specs/2026-09-02-path-based-routing-design.md
+++ b/docs/superpowers/specs/2026-09-02-path-based-routing-design.md
@@ -98,6 +98,12 @@ A prefix is stored as a **bare segment, no leading slash** — `paulca`, not
one spelling and no normalisation step to get wrong. Surfaces may render it
with a slash for readability; nothing else adds one.
+That is a statement about the **stored prefix**, and only about it. The
+**request path** is attacker-shaped and arrives in whatever spelling a client
+chooses, so the matcher does normalise it — see §6, which describes what it
+normalises and why one canonical form is not enough. The two are separate
+problems: one spelling on the write side, several on the read side.
+
`go.tito.io` is already a route carrying its own channel, environment, health
probe, hibernation policy and usage metering. The row that points at it
carries no upstream, no channel and no environment of its own. Nothing is
@@ -255,20 +261,61 @@ does a second lookup happen:
1. Resolve `host` exactly as today (exact, then one-label wildcard).
2. If that host has no prefix map — every host on the platform today — return
the route. One nil map check, no allocation, no behaviour change.
-3. Otherwise take the first path segment and look it up. A hit returns the
+3. Otherwise take the first path segment — in **both** its raw and its
+ normalised spelling, see below — and look it up. A hit returns the
target's upstream; a miss returns the host's own route.
**No prefix stripping.** `ti.to/paulca/awesomeconf` arrives at Tito Go as
`/paulca/awesomeconf`. Go already knows the account is the first segment
because that is how the URLs are shaped; stripping would make the flipped and
unflipped URL spaces differ, which is exactly what a seamless migration must
-not do.
-
-**Segment-boundary matching only.** Because the stored prefix is a bare
-segment (§2), the match is a plain equality test against the request's first
-segment — never a `strings.HasPrefix` on the raw path. So the prefix `paulca`
-serves `/paulca` and `/paulca/awesomeconf/admin`, and never `/paulcarson`.
-A request for `/` matches no prefix and falls through to the host's own route.
+not do. This holds for the odd shapes too: matching normalises, **proxying
+never does**, so the upstream always receives the request's original bytes.
+
+**Segment-boundary matching, on both spellings of the first segment.** Because
+the stored prefix is a bare segment (§2), the match is a plain equality test
+against the request's first segment — never a `strings.HasPrefix` on the raw
+path. So the prefix `paulca` serves `/paulca` and `/paulca/awesomeconf/admin`,
+and never `/paulcarson`. A request for `/` matches no prefix and falls through
+to the host's own route.
+
+"The request's first segment" is two strings, not one, and the map is
+consulted for both: the **raw** split of the path as it arrived, and the
+**normalised** one (`path.Clean` first, so leading empty segments and dot
+segments resolve). Either may hit; when both hit and they name different
+prefixes, the normalised one wins.
+
+Both are needed because they fail in opposite directions, and the invariant
+being protected is one-way — **the app an account was flipped away from must
+never answer for that account.** Serving a not-yet-flipped account from the
+new app is the benign direction, at worst a 404 while the migration runs.
+
+- **Raw alone** misses `//paulca/foo`. Most backends, Rails included, collapse
+ that to `/paulca/foo` and serve it, so the request would fall through to the
+ legacy app — which then answers for a flipped account. One character,
+ silent.
+- **Normalised alone** misses `/paulca/../bob`, which resolves to `bob`. A
+ backend that does not collapse `..`, or that routes on the first segment
+ with a glob like `/:account/*rest`, would then serve paulca's page from the
+ legacy app. The same bypass from the other side.
+
+Taking a hit from either closes both. The cost is that `/paulca/../bob`
+reaches the new app carrying `/paulca/../bob`, which a normalising target
+resolves to `/bob` and 404s — an account it was not given. Benign, and
+deliberate.
+
+**Residue**, recorded so it is not re-derived later. None of it crosses the
+invariant above:
+
+- `/%2e%2e/bob` — `r.URL.Path` is already percent-decoded, so this normalises
+ to `/bob` and matches `bob`. The upstream receives the raw `/%2e%2e/bob` and
+ will most likely 404 it. Harmless.
+- `/paulca%2ffoo` — decodes to `/paulca/foo`, so both spellings match `paulca`
+ and the request reaches the new app, while a strict backend treats the
+ upstream path as the single segment `paulca/foo`. A mis-flip toward the new
+ app: the safe direction.
+- A prefix literally named `.` or `..` cannot exist — `validPathPrefix`
+ refuses both — so the raw spelling of `/../bob` can never match anything.
**Three call sites, and they do not all want a path** (this is the subtle part):