carlos / platform Public

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
Paul Campbell pushed by paul@keymail.dev a55fbe5f48121ebacece3276d225507a69487b35 parent f975b47
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.