carlos / platform Public
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.Copyper request — and the 32k lines ininternal/edgeare 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.tois 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:
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 -> classicA 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:
// 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.Recordis 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 etaggetClaimETagalready 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
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:
fmt.Fprint(h, "\x1droute_paths\x1e")
// SELECT host, prefix, target, account, app FROM route_paths ORDER BY host, prefixAll 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:
- Resolve
hostexactly as today (exact, then one-label wildcard). - 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.
- 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/fooand 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 tobob. 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.Pathis already percent-decoded, so this normalises to/boband matchesbob. The upstream receives the raw/%2e%2e/boband will most likely 404 it. Harmless./paulca%2ffoo— decodes to/paulca/foo, so both spellings matchpaulcaand the request reaches the new app, while a strict backend treats the upstream path as the single segmentpaulca/foo. A mis-flip toward the new app: the safe direction.- A prefix literally named
.or..cannot exist —validPathPrefixrefuses both — so the raw spelling of/../bobcan 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— aroutablecheck 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-Versionheader 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}/pathsWire shape. The body is the whole map, WRAPPED in a paths field — not a bare map:
{ "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 routesgains 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/loginandti.to/assetsmust 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.tomigration. 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.
- Table, wire field, fingerprint section. Additive; zero rows on every existing deployment; no behaviour change. The fingerprint test from §5 lands here.
- Reconcile derivation + tenancy gates, including a test that a target in another account is refused.
lookupUpstream(host, path)plus the three call sites, with theedge.go:1455host-only form preserved.- The write door and
SetClaimPaths. - End-to-end proof on a scratch host. Two trivial apps,
AandB, on one hostname:scratch.example/breachesB, everything else reachesA,/batreachesA(boundary), removing the entry from a republished map restoresAwithin ~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.pagebe created through a supported door?provision.ValidHostShape(provision.go:250) checks every label againstlabelRe, which a literal*cannot pass — so wildcard rows arrive by some other route (aWildcard+Catchallclaim, 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.tobehind 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
ReconcileAliasesandEdge.Reloaddirectly, 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:1475and:1955, the two production call sites ofReconcileAliases) 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.toon one box andgo.tito.ioon 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.