# Path-based routing — one hostname, many upstreams

**Date:** 2026-09-02 · **Status:** draft, awaiting review · **Requested by:** Paul

**Builds on:** #151 (the global claim, `route_aliases`, the `domains` door),
2026-08-27-managed-custom-domains-design.md (the claim → pass-owned-table
idiom this spec extends), 2026-08-20-multi-region-ingress-design.md (the 2s
fingerprint poll this spec must not bypass).

## 0. How this spec was arrived at

Four rounds of clarification with Paul. The scope rulings that shaped it:

- **No Rust rewrite.** The router question that started this is dead. The
  edge is not CPU-bound — a map lookup and an `io.Copy` per request — and the
  32k lines in `internal/edge` are certsync, backhaul, tunnels, hibernation
  and purge, not proxy plumbing. A Rust edge would split one Go binary into
  two build systems, two artifacts and two release channels to buy nothing
  measurable. Path routing is a trie lookup layered on a map lookup; in Go
  the question dissolves.
- **Tito's platform owns the flip, not CARLOS.** Paul: *"i think the 'flip'
  would happen on the tito platform side, no?"* Correct, and it is the
  load-bearing ruling in this document. Tito knows which accounts are
  migrated. CARLOS must not learn Tito's account model. CARLOS supplies a
  routing primitive and a write door; Tito is the writer and the source of
  truth.
- **`ti.to` is NOT moving behind the CARLOS edge yet.** Paul: *"ti.to behind
  CARLOS edge is not something I'm ready for, but I'm happy to deploy the
  machinery."* So this spec builds and proves the machinery with no
  production consumer on day one. §10 is how that is proven honestly rather
  than by assertion.
- **A route per prefix** as the routing model (Paul's explicit selection),
  refined by §2 into a prefix that *selects* a route rather than defining one.

---

## 1. The two cases, and why only one needs code

**Case 1 — Tito classic on `ti.to`, path-based.** Today `ti.to/paulca` is the
account page, `ti.to/paulca/awesomeconf` the event, and
`ti.to/paulca/awesomeconf/admin` the admin. The first path segment is the
account, and it is the flip discriminator. Flipping one account must move
that account's whole subtree to Tito Go, reversibly, without touching the
other accounts. **This needs new code.**

**Case 2 — Tito Pro on `*.tito.page`, host-based.** Pro customers each get a
CNAME like `deutsche-filmakademie.tito.page`. Flipping one means pointing
that hostname at Tito Go.

Case 2 needs **no new routing code**. `lookupUpstream` (edge.go:866) tries an
exact host match before the one-label wildcard fallback, and that ordering is
a documented guarantee, not an accident:

```go
func (e *Edge) lookupUpstream(host string) (upstream, bool) {
	m := e.current()
	if up, ok := m[host]; ok {           // exact
		return up, true
	}
	if i := strings.IndexByte(host, '.'); i > 0 {
		if up, ok := m["*"+host[i:]]; ok {   // one-label wildcard
			return up, true
		}
	}
	return upstream{}, false
}
```

So a catch-all `*.tito.page` row pointing at classic, plus an exact
`deutsche-filmakademie.tito.page` row pointing at Go, gives a per-customer
flip today. Rollback is deleting the exact row. What is *not* yet proven is
the operational path to that state — see §11.

The rest of this spec is case 1.

---

## 2. The model: a prefix selects a route, it does not define one

The obvious design gives each prefix its own upstream, channel and
environment. That is wrong: it duplicates a route's entire configuration per
account, and Tito already has both backends as real routes.

Instead, mirror `route_aliases`, which already works exactly this way — an
alias is a *pointer* to a primary host, and the primary supplies everything
else. A path route is that same pointer with a prefix attached:

```
route_paths(host, prefix) -> target_host

  ti.to  paulca    -> go.tito.io
  ti.to  bigcorp   -> go.tito.io
  ti.to  (default) -> ti.to's own route -> classic
```

A prefix is stored as a **bare segment, no leading slash** — `paulca`, not
`/paulca` — on the wire, in the table and in the lookup, so there is exactly
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
duplicated, and every existing subsystem keeps working because the thing
being selected is an ordinary route.

This also buys something that was not asked for and costs nothing:
**canarying a subset of accounts.** Point most flipped accounts at the stable
Go route and a handful at a second Go route bound to a beta channel. It falls
out of the model; there is no special case for it.

**Migration progress is the row count.** `route_paths` holds only flipped
accounts. Rollback for one account is one row removed. When everyone is
across, repoint `ti.to`'s own route at Go and drop the rows entirely.

---

## 3. Wire: path routes are a third claim capability

The control-plane object already exists. A claim (`internal/domains/claims.go:34`)
lives at `domains/<host>.json` — **one object per hostname**, which is exactly
the granularity a per-host prefix map wants. It already carries `Account` and
`App` for tenancy, is written under a CAS etag, and is folded into the
registry on the fast-converge leg every ~2s.

It is also explicitly built to grow:

> *"any future claim field must be added here in the same change that first
> writes it"* — claims.go:50-55

So the wire change is one additive field:

```go
// Paths maps a leading URL path segment to the route that serves it,
// for a host whose traffic is split by path rather than by hostname.
// Keys are single segments WITHOUT slashes ("paulca"); values are
// hostnames of routes in the same account. Absent or empty means this
// host routes by hostname alone — the behaviour of every host today.
Paths map[string]string `json:"paths,omitempty"`
```

**Tito publishes this wholesale, regenerated from its own database.** Not
deltas. A lost delta desyncs the two systems silently and nobody finds out
until a customer reports a wrong page; a wholesale republish is a full
repair, and republishing is always safe. One `PUT` per flip regardless of how
many accounts are in the map.

Deliberate departures, called out because they cut against neighbouring
idioms:

- **Replace-on-write, not create-only.** `provision.Record` is create-only
  (409 on race, never a silent overwrite) because an instance record is a
  one-shot fact. A migration toggle is neither one-shot nor one-way. The CAS
  etag `getClaimETag` already carries is what keeps concurrent writes honest.
- **A map field, not one object per flip.** One object per account would mean
  10k+ bucket objects for a state that is one table in Tito's database.

**Cap.** Mirror `maxWildcardDomains` (aliases.go:25) with `maxPathsPerClaim =
10000` — deterministic drops in sorted key order, logged, never silent.
Unlike the wildcard cap this does not defend an external budget (there is no
ACME equivalent here); it bounds object size and the per-box fold. At ~40
bytes an entry, 10k entries is ~400KB re-Put per flip and refolded every 2s
per box, which is comfortable. Sharding a host's map across objects is out of
scope and should not be built until a real host approaches the cap.

---

## 4. Registry: `route_paths`, wholly pass-owned

```sql
CREATE TABLE IF NOT EXISTS route_paths (
  host    TEXT NOT NULL,
  prefix  TEXT NOT NULL,
  target  TEXT NOT NULL,
  account TEXT NOT NULL,
  app     TEXT NOT NULL,
  PRIMARY KEY (host, prefix)
);
```

`host TEXT PRIMARY KEY` on `routes` is what forced this to be a separate
table: a hostname is one row there, and it must stay one row, because that
row is what owns the cert, the home box, the DNS state and the aliases.

Derivation extends `ReconcileAliases` (`internal/domains/aliases.go:48`),
which already folds claims into `route_aliases` and `wildcard_domains` on the
same pass, and already states the property this table needs:

> *"Both tables are wholly pass-owned: every row traces to a claim, so
> removal needs no ownership flag."*

Same rule here. Every `route_paths` row traces to a claim's `Paths` entry;
rows with no claim behind them are deleted every pass. A republished map with
an account removed un-flips that account on the next tick, with no delete
verb anywhere.

Tenancy gates, mirroring the existing alias gates in the same loop:

- The claim's host must resolve to a local route whose account and app match
  the claim — the check already there for aliases.
- **The target must belong to the same account.** A prefix must never be able
  to point another tenant's traffic at your backend. This is the one genuinely
  new authorization rule in the spec and the one most worth reviewing.
- A target that is not a local ready route contributes no row, and logs. The
  prefix then falls through to the host's default — a missing Go route serves
  classic, which is the correct failure direction for a migration.

`Route.Slug` (registry.go:36 — *"app-account short name usable on a root
domain. Empty = subdomain-only"*) is carried by the registry and by
`provision.Record` and read by nothing. It is an unwired hook for root-domain
path routing that someone anticipated. This spec does **not** use it:
`route_paths` is Tito-published desired state, while `Slug` is a property a
route asserts about itself, and conflating them would give two writers of one
routing decision. Leave it alone; if it is still dead after this ships, delete
it in a separate change.

---

## 5. `Fingerprint()` must fold in `route_paths`

`Fingerprint()` (registry.go:1676) is the 2s content hash the edge polls, and
it hashes `routes`, `fleets`, `fleet_boxes`, `route_aliases` and
`wildcard_domains` — each under its own section marker so a value can never
migrate between sections and cancel a change out.

Add a `route_paths` section on the same pattern:

```go
fmt.Fprint(h, "\x1droute_paths\x1e")
// SELECT host, prefix, target, account, app FROM route_paths ORDER BY host, prefix
```

All five columns hash; there is no cadence-stamped column here to exclude, so
this table needs none of the `dns_status`/`cert_expires_at` care that
`route_aliases` does.

**This is the easiest thing in the spec to omit and the worst to omit.** The
failure mode is silent: flips write correctly, reconcile correctly, land in
the table correctly, and never reach the live map — the edge keeps serving the
old answer until something unrelated moves the digest. The function's own
header already names this class of bug as the one to engineer against:

> *"the failure mode worth engineering against is the false NEGATIVE."*

A test that asserts the digest moves when a `route_paths` row changes is a
required deliverable, not a nice-to-have.

---

## 6. The edge: `lookupUpstream(host, path)`

Host resolution is unchanged. Only when the resolved host has a prefix map
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 — 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. 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.

**The scope of the guarantee**, stated so it is not over-read. The two
spellings model exactly two normalisations — percent-decoding and dot-segment
removal — and the invariant holds across every combination of a backend doing
or not doing those two. It is *not* a claim that no other normalisation
exists. A backend that folds case (`/PAULCA/foo`), strips servlet-style path
parameters (`/paulca;jsessionid=x/foo`), accepts backslash separators, strips
trailing dots, or double-decodes could resolve a path neither spelling matches
into a flipped account's subtree.

Rails does none of these — Rack keeps `;` in `PATH_INFO`, and routing is
case-sensitive — so Tito is not exposed, which is why this is recorded rather
than defended against. A future consumer with different normalisation
behaviour needs this reasoning redone, not inherited.

**Three call sites, and they do not all want a path** (this is the subtle part):

- `edge.go:930` — the ServeHTTP dispatch. Takes the path.
- `edge.go:1455` — a `routable` check asking only *is this hostname served
  here at all*. Must stay host-only; giving it a path would make a hostname's
  routability depend on which URL happened to ask.
- `edge.go:866` — the definition.

So the host-only form stays as the primitive and the path-aware form wraps
it, rather than the path-aware form replacing it everywhere.

`ServeHTTP`'s ordering (edge.go:879) is load-bearing and documented: steering
probe → reserved tunnel hostname / store door → `acceptHop` → lookup → miss
handling → usage metering wrapping `w` exactly once. Path resolution goes
**inside** the existing lookup step. It must not move anything above it —
particularly not `acceptHop`, or a backhauled request would resolve its path
twice, once on each box.

Untouched: `homemap`, `backhaul`, `certsync`, `certwarm`, `wildcard`, the
activator, the cache. That is the payoff of keeping the hostname as the unit
of placement: `ti.to` is one hostname with one home box and one cert whatever
its prefix map says, so none of that machinery has to learn about paths.
`homemap`'s lookup is documented as mirroring `lookupUpstream` *"rule for
rule"* — this spec keeps that true, because the rule it mirrors is the
host-only one.

**But that is a statement about the HOSTNAME, not a promise that path routing
works across boxes. It does not.** Both halves of the resolution — the
reconcile pass's `byHost` and the edge's live map — are built from *this
box's* routes, and backhaul fires only on a **host** miss, never for a path
target. So the constraint is:

> **A prefix's target route must be homed on the same box as the hostname's
> own route.** A target on another box derives no `route_paths` row anywhere,
> on any box, and every request keeps reaching the hostname's own route.

That failure direction is safe (traffic serves the origin, never a 404) but it
is invisible in the traffic, so the reconcile pass logs it: one aggregated
line per host per pass naming the count and the first few unresolved targets,
distinguishing "the target does not exist" from "the target is homed
elsewhere" because the remedies differ. Making cross-box path targets actually
work needs a backhaul equivalent for path targets, which is a design change,
not a fix. See §11.

**Attribution: a path hit returns the TARGET's upstream entry wholesale.**
Everything downstream that reads the resolved entry reads the target's route,
not the requested hostname's. Four consequences, three of them correct by
design and one a known gap:

- **Usage metering** bills the target's route, account and app. Correct — the
  target's backend burned the bytes. Pinned by a test so a later "fix" in the
  other direction is caught.
- **The `X-Carlos-Version` header** reports the target's release. Correct — it
  names the code that produced the response.
- **Activator keying and hibernation** are the target's, so a flipped prefix
  wakes the target instance. Correct — that is the process that has to be
  awake.
- **Purge tenancy** is the target's, while the response **cache keys on the
  requested hostname**. That mismatch is a real gap; §11 records it and its
  bound.

---

## 7. The write door

An earlier revision of this design claimed a route write API was net-new
work, because `internal/console/apiroutes.go` is read-only. That was looking
in the wrong place. The claim API already exists, with service-role scoping
(`internal/console/authcli.go:618-624`):

```
GET    /api/cli/apps/{account}/{app}/domains
POST   /api/cli/apps/{account}/{app}/domains
GET    /api/cli/apps/{account}/{app}/domains/{host}
DELETE /api/cli/apps/{account}/{app}/domains/{host}
```

Tito's door is a sibling:

```
PUT /api/cli/apps/{account}/{app}/domains/{host}/paths
```

**Wire shape.** The body is the whole map, WRAPPED in a `paths` field — not a
bare map:

```json
{ "paths": { "paulca": "go.tito.io", "bigcorp": "go.tito.io" } }
```

`{"paths": {}}` is how a map is cleared. A bare map, or a body whose `paths`
field is absent or misspelled, answers **400**. The wrapper is what makes that
possible: `paths` is decoded into a POINTER, so "absent" and "empty" are
distinguishable, and a typo in the field name becomes a refusal instead of a
silent wholesale clear of every flip on the hostname. (An earlier revision of
this spec said "body is the whole map", which would fail on the first call —
corrected here.)

It reuses the existing auth, account scoping and service roles wholesale, and
its handler is the shape of the capability handler at apidomains.go:482: read
claim under etag, replace one field, CAS write.

**Roles: `Publish` AND `Instance`.** The first implementation made this
`Publish` only, reasoning that deciding whose traffic leaves the legacy app is
a deployment-shaped act. Review overturned that on internal coherence: the
paths *read* (`GET .../domains/{host}`) is gated `Publish+Instance`, so
`Publish`-only left an Instance credential able to read a hostname's published
map and not write it — and `apidomains.go`'s own file header names Tito's
router, calling this family with its `CARLOS_INSTANCE_TOKEN`, as the caller
the door exists for. Widening crosses no tenancy boundary: the door is
owner-gated on account and app, so an Instance token can only ever publish its
own app's map, and the reconcile pass re-checks every target against the same
account regardless. Recorded here so the scoping is a visible choice rather
than an accident.

**`refuseServiceControlPlane`.** A SERVICE credential of any role is refused
outright when the app is the deployment's own control plane; a person is not.
A prefix map on the console's own hostname would hand a slice of the console's
URL space (`/admin`, `/api`) to whatever else that account runs, and that is
not a decision a CI or router credential should be able to take. The guard
sits before the hostname is normalized, so its answer cannot vary with host
shape — a flagged app refuses a malformed host and a valid one with the
identical status and body.

Following `SetClaimCapabilities`' precedent (claims.go:279, *"the ONLY writer
of a claim's Wildcard/Catchall fields"*), a single `SetClaimPaths` is the only
writer of `Paths`, so `AttachClaim`'s converge can neither invent nor strip
the map.

**Flip latency, end to end:** `PUT` → bucket → **serial bump** →
fast-converge tick (`fastTickEvery = 2s` plus jitter, agent.go:1053/1504) →
registry write → 2s fingerprint poll → live map. **Under about five
seconds**, which is inside what a Tito admin toggle can plausibly wait on.

The serial bump is not incidental, and this document's first revision
omitted it — the omission was caught during implementation. Without it the
write lands in the bucket correctly and then waits for the *hourly* pass,
turning a five-second flip into an hour-long one. Attach and detach already
bump for exactly this reason, so the door must do what its siblings do:
write, then audit, then bump, all inside the `changed` branch so a no-op
republish costs neither an audit row nor a fleet-wide reload.

The audit row matters independently of latency. A prefix map decides where
an account's traffic goes, so "who moved this hostname's traffic, and when"
has to be answerable afterwards. The row records actor, account, app, host
and the prefix count — deliberately not the targets themselves, since the
map may hold ten thousand entries.

---

## 8. Surfaces

- `carlos domains paths <host>` — list a host's prefix map.
- `carlos routes` gains path rows, indented under their host, so the flipped
  set is visible from the same command operators already use.
- Both read-only. Tito is the writer; a CLI write verb would create a second
  writer of state whose source of truth is Tito's database, and the two would
  drift the first time anyone used it.

---

## 9. What this does NOT do

- **No regex, globs, or multi-segment prefixes.** First segment, exact match.
  Tito's URL shape is `/<account>/...` and nothing here should outgrow that
  before there is a second consumer to generalise against.
- **No reserved-prefix list.** `ti.to/login` and `ti.to/assets` must not be
  shadowed by an account slug — but Tito already guarantees that, because
  those names cannot be registered as accounts. Duplicating the list in CARLOS
  gives two places to update and one to forget. If this proves wrong, it is a
  small additive change to the reconcile gate.
- **No per-prefix channel or environment.** Point the prefix at a route that
  has them (§2).
- **No path-aware certs, DNS, or placement.** A hostname remains the unit of
  all three.
- **No `ti.to` migration.** Explicitly out of scope per Paul's ruling.

---

## 10. Rollout, and how this is proven without `ti.to`

The honest risk of building machinery with no consumer is that it ships
looking finished and is wrong in a way nobody discovers for months. So the
acceptance bar is an end-to-end flip on a real hostname, not unit tests.

1. **Table, wire field, fingerprint section.** Additive; zero rows on every
   existing deployment; no behaviour change. The fingerprint test from §5
   lands here.
2. **Reconcile derivation + tenancy gates**, including a test that a target
   in another account is refused.
3. **`lookupUpstream(host, path)`** plus the three call sites, with the
   `edge.go:1455` host-only form preserved.
4. **The write door and `SetClaimPaths`.**
5. **End-to-end proof on a scratch host.** Two trivial apps, `A` and `B`, on
   one hostname: `scratch.example/b` reaches `B`, everything else reaches `A`,
   `/bat` reaches `A` (boundary), removing the entry from a republished map
   restores `A` within ~5s. This is the deliverable that says the machinery
   works — everything before it is scaffolding.

Stages 1-4 are independently landable PRs. Nothing is observable until 5.

---

## 11. Live checks owed, and known gaps

None is a blocker for stages 1-5; all are blockers before Tito relies on
any of it.

- **Case 2, end to end.** Can `*.tito.page` be created through a supported
  door? `provision.ValidHostShape` (provision.go:250) checks every label
  against `labelRe`, which a literal `*` cannot pass — so wildcard rows arrive
  by some other route (a `Wildcard`+`Catchall` claim, per aliases.go, is the
  likely one). And the delegated wildcard cert path needs proving with a real
  handshake. This is read-from-code, not observed. **Case 2 is the case Paul
  can act on soonest, and it is the one with the least evidence behind it.**
- **`ti.to` behind the CARLOS edge.** Out of scope here, but it is the
  prerequisite for case 1 ever being used, and it is the largest single risk
  in the whole migration. Worth its own spec when Paul is ready.

- **The production converge loop actually folds a path claim on schedule.**
  §10's end-to-end test drives `ReconcileAliases` and `Edge.Reload` directly,
  which is the right call — the 2s tick is a latency bound, not a correctness
  property, and polling it would make the fastest test in the plan slow and
  flaky. But that means the test proves the layers compose *given that
  something calls those two functions*. It does not prove that the real
  converge loop (`internal/host/agent.go:1475` and `:1955`, the two
  production call sites of `ReconcileAliases`) invokes them for a live path
  claim on the real schedule.

  The general fingerprint→reload mechanism is not unproven — `Edge.Watch` is
  exercised against a live goroutine with bounded polling elsewhere in
  `internal/edge`. What is unproven is specifically that a published `paths`
  map traverses the real host agent's timing end to end. That is an
  integration concern a package-level test cannot absorb, so it belongs here
  rather than in the suite. **It is also the check that would falsify §7's
  "under about five seconds" claim if that claim is wrong**, which makes it
  the most load-bearing of the three items in this section.

- **Cross-box path targets.** Both halves of the path resolution read this
  box's routes only, and there is no backhaul equivalent for a path target
  (§6). `ti.to` on one box and `go.tito.io` on another — the likely shape for
  two apps as different as Tito classic and Tito Go — derives no rows and
  routes nothing, forever. The reconcile pass now says so in the log, but
  nothing enforces the constraint at the write door and nothing has ever been
  observed. Before any real flip: confirm the hostname's route and every
  target route are homed on the same box, and decide whether making it work
  across boxes is wanted (a design change, not a fix).

**Resolved: a path-routed app purging its own cache.** Shipped as a known gap
and closed straight after, once it became clear the platform might carry more
than a two-app migration.

The problem was real. The response cache keys on the REQUESTED hostname
(`ti.to`), while `purgeModifier` is built from the resolved route — the
TARGET's (`go.tito.io`). So a bare `Carlos-Purge` from the Go app purged under
`go.tito.io` and dropped nothing, because the entry lives under `ti.to`. The
documented `Carlos-Purge-Host: ti.to` escape hatch did not help either:
`resolvePurgeHost` required the override to name a host in the same app, and
path routing exists precisely to cross an app boundary. Aliases never hit
this, because an alias maps to the primary's own route and the app comparison
passes trivially.

`resolvePurgeHost` now accepts a cross-app override when a prefix on the named
host selects the serving route. That grants nothing new: the edge is already
routing that host's traffic here, so this route serves pages under that
hostname. **Account is still required**, and is checked at the edge rather than
inherited from the reconcile pass — a path row written by an older binary or by
hand must not reach another tenant's cache. The exception is scoped to the
route the prefix map actually names, so "the front host path-routes somewhere"
is not enough.

**What an app must do:** emit `Carlos-Purge-Host` set to the `Host` header it
received, unconditionally. The inbound Host is preserved end to end, so on an
ordinary request that equals the serving route's own host and takes the
existing own-host branch; on a path-routed one it names the front hostname and
takes the new branch. One line of shared code, and nothing in the app needs to
know whether it is path-routed.
