docs: path-based routing design — one hostname, many upstreams
Design for routing a single hostname to different upstreams by leading path segment, so Tito can migrate accounts from classic to Tito Go one at a time and reverse any of them. The load-bearing decisions: - A prefix SELECTS an existing route rather than defining a backend, mirroring route_aliases. Channel, environment, health and upstream all come from the target route, so a prefix row duplicates nothing — and pointing a subset of accounts at a beta-channel route falls out for free. - Path routes are a third claim capability, not a new control-plane object type. domains/<host>.json is already one object per hostname, CAS-written, tenancy-carrying and folded into the registry every ~2s by ReconcileAliases, whose tables are already wholly pass-owned. - Tito's platform is the writer and the source of truth, publishing the whole prefix map wholesale so a lost delta can never desync the two systems. - Fingerprint() must gain a route_paths section or flips reconcile correctly and never reach the live map — a silent false negative, the failure class that function's own header names. ti.to is explicitly NOT moving behind the CARLOS edge in this work (Paul's ruling); the acceptance bar is an end-to-end flip on a scratch host instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MU8d4TRrugKC14aJoEZXU7
1 file changed,
+405
−0
docs/superpowers/specs/2026-09-02-path-based-routing-design.md+405 −0
CI passed — run details
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| new file mode 100644 |
| index 000000000..b78562bec |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-09-02-path-based-routing-design.md |
| @@ -0,0 +1,405 @@ |
| +# 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. |
| + |
| +`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 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. |
| + |
| +**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 the cross-box machinery never 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. |
| + |
| +--- |
| + |
| +## 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 |
| +``` |
| + |
| +Body is the whole map. 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. |
| + |
| +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 → 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. |
| + |
| +--- |
| + |
| +## 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 |
| + |
| +Neither is a blocker for stages 1-5; both 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. |