| 1 | # Path-based routing — one hostname, many upstreams |
| 2 | |
| 3 | **Date:** 2026-09-02 · **Status:** draft, awaiting review · **Requested by:** Paul |
| 4 | |
| 5 | **Builds on:** #151 (the global claim, `route_aliases`, the `domains` door), |
| 6 | 2026-08-27-managed-custom-domains-design.md (the claim → pass-owned-table |
| 7 | idiom this spec extends), 2026-08-20-multi-region-ingress-design.md (the 2s |
| 8 | fingerprint poll this spec must not bypass). |
| 9 | |
| 10 | ## 0. How this spec was arrived at |
| 11 | |
| 12 | Four rounds of clarification with Paul. The scope rulings that shaped it: |
| 13 | |
| 14 | - **No Rust rewrite.** The router question that started this is dead. The |
| 15 | edge is not CPU-bound — a map lookup and an `io.Copy` per request — and the |
| 16 | 32k lines in `internal/edge` are certsync, backhaul, tunnels, hibernation |
| 17 | and purge, not proxy plumbing. A Rust edge would split one Go binary into |
| 18 | two build systems, two artifacts and two release channels to buy nothing |
| 19 | measurable. Path routing is a trie lookup layered on a map lookup; in Go |
| 20 | the question dissolves. |
| 21 | - **Tito's platform owns the flip, not CARLOS.** Paul: *"i think the 'flip' |
| 22 | would happen on the tito platform side, no?"* Correct, and it is the |
| 23 | load-bearing ruling in this document. Tito knows which accounts are |
| 24 | migrated. CARLOS must not learn Tito's account model. CARLOS supplies a |
| 25 | routing primitive and a write door; Tito is the writer and the source of |
| 26 | truth. |
| 27 | - **`ti.to` is NOT moving behind the CARLOS edge yet.** Paul: *"ti.to behind |
| 28 | CARLOS edge is not something I'm ready for, but I'm happy to deploy the |
| 29 | machinery."* So this spec builds and proves the machinery with no |
| 30 | production consumer on day one. §10 is how that is proven honestly rather |
| 31 | than by assertion. |
| 32 | - **A route per prefix** as the routing model (Paul's explicit selection), |
| 33 | refined by §2 into a prefix that *selects* a route rather than defining one. |
| 34 | |
| 35 | --- |
| 36 | |
| 37 | ## 1. The two cases, and why only one needs code |
| 38 | |
| 39 | **Case 1 — Tito classic on `ti.to`, path-based.** Today `ti.to/paulca` is the |
| 40 | account page, `ti.to/paulca/awesomeconf` the event, and |
| 41 | `ti.to/paulca/awesomeconf/admin` the admin. The first path segment is the |
| 42 | account, and it is the flip discriminator. Flipping one account must move |
| 43 | that account's whole subtree to Tito Go, reversibly, without touching the |
| 44 | other accounts. **This needs new code.** |
| 45 | |
| 46 | **Case 2 — Tito Pro on `*.tito.page`, host-based.** Pro customers each get a |
| 47 | CNAME like `deutsche-filmakademie.tito.page`. Flipping one means pointing |
| 48 | that hostname at Tito Go. |
| 49 | |
| 50 | Case 2 needs **no new routing code**. `lookupUpstream` (edge.go:866) tries an |
| 51 | exact host match before the one-label wildcard fallback, and that ordering is |
| 52 | a documented guarantee, not an accident: |
| 53 | |
| 54 | ```go |
| 55 | func (e *Edge) lookupUpstream(host string) (upstream, bool) { |
| 56 | m := e.current() |
| 57 | if up, ok := m[host]; ok { // exact |
| 58 | return up, true |
| 59 | } |
| 60 | if i := strings.IndexByte(host, '.'); i > 0 { |
| 61 | if up, ok := m["*"+host[i:]]; ok { // one-label wildcard |
| 62 | return up, true |
| 63 | } |
| 64 | } |
| 65 | return upstream{}, false |
| 66 | } |
| 67 | ``` |
| 68 | |
| 69 | So a catch-all `*.tito.page` row pointing at classic, plus an exact |
| 70 | `deutsche-filmakademie.tito.page` row pointing at Go, gives a per-customer |
| 71 | flip today. Rollback is deleting the exact row. What is *not* yet proven is |
| 72 | the operational path to that state — see §11. |
| 73 | |
| 74 | The rest of this spec is case 1. |
| 75 | |
| 76 | --- |
| 77 | |
| 78 | ## 2. The model: a prefix selects a route, it does not define one |
| 79 | |
| 80 | The obvious design gives each prefix its own upstream, channel and |
| 81 | environment. That is wrong: it duplicates a route's entire configuration per |
| 82 | account, and Tito already has both backends as real routes. |
| 83 | |
| 84 | Instead, mirror `route_aliases`, which already works exactly this way — an |
| 85 | alias is a *pointer* to a primary host, and the primary supplies everything |
| 86 | else. A path route is that same pointer with a prefix attached: |
| 87 | |
| 88 | ``` |
| 89 | route_paths(host, prefix) -> target_host |
| 90 | |
| 91 | ti.to paulca -> go.tito.io |
| 92 | ti.to bigcorp -> go.tito.io |
| 93 | ti.to (default) -> ti.to's own route -> classic |
| 94 | ``` |
| 95 | |
| 96 | A prefix is stored as a **bare segment, no leading slash** — `paulca`, not |
| 97 | `/paulca` — on the wire, in the table and in the lookup, so there is exactly |
| 98 | one spelling and no normalisation step to get wrong. Surfaces may render it |
| 99 | with a slash for readability; nothing else adds one. |
| 100 | |
| 101 | That is a statement about the **stored prefix**, and only about it. The |
| 102 | **request path** is attacker-shaped and arrives in whatever spelling a client |
| 103 | chooses, so the matcher does normalise it — see §6, which describes what it |
| 104 | normalises and why one canonical form is not enough. The two are separate |
| 105 | problems: one spelling on the write side, several on the read side. |
| 106 | |
| 107 | `go.tito.io` is already a route carrying its own channel, environment, health |
| 108 | probe, hibernation policy and usage metering. The row that points at it |
| 109 | carries no upstream, no channel and no environment of its own. Nothing is |
| 110 | duplicated, and every existing subsystem keeps working because the thing |
| 111 | being selected is an ordinary route. |
| 112 | |
| 113 | This also buys something that was not asked for and costs nothing: |
| 114 | **canarying a subset of accounts.** Point most flipped accounts at the stable |
| 115 | Go route and a handful at a second Go route bound to a beta channel. It falls |
| 116 | out of the model; there is no special case for it. |
| 117 | |
| 118 | **Migration progress is the row count.** `route_paths` holds only flipped |
| 119 | accounts. Rollback for one account is one row removed. When everyone is |
| 120 | across, repoint `ti.to`'s own route at Go and drop the rows entirely. |
| 121 | |
| 122 | --- |
| 123 | |
| 124 | ## 3. Wire: path routes are a third claim capability |
| 125 | |
| 126 | The control-plane object already exists. A claim (`internal/domains/claims.go:34`) |
| 127 | lives at `domains/<host>.json` — **one object per hostname**, which is exactly |
| 128 | the granularity a per-host prefix map wants. It already carries `Account` and |
| 129 | `App` for tenancy, is written under a CAS etag, and is folded into the |
| 130 | registry on the fast-converge leg every ~2s. |
| 131 | |
| 132 | It is also explicitly built to grow: |
| 133 | |
| 134 | > *"any future claim field must be added here in the same change that first |
| 135 | > writes it"* — claims.go:50-55 |
| 136 | |
| 137 | So the wire change is one additive field: |
| 138 | |
| 139 | ```go |
| 140 | // Paths maps a leading URL path segment to the route that serves it, |
| 141 | // for a host whose traffic is split by path rather than by hostname. |
| 142 | // Keys are single segments WITHOUT slashes ("paulca"); values are |
| 143 | // hostnames of routes in the same account. Absent or empty means this |
| 144 | // host routes by hostname alone — the behaviour of every host today. |
| 145 | Paths map[string]string `json:"paths,omitempty"` |
| 146 | ``` |
| 147 | |
| 148 | **Tito publishes this wholesale, regenerated from its own database.** Not |
| 149 | deltas. A lost delta desyncs the two systems silently and nobody finds out |
| 150 | until a customer reports a wrong page; a wholesale republish is a full |
| 151 | repair, and republishing is always safe. One `PUT` per flip regardless of how |
| 152 | many accounts are in the map. |
| 153 | |
| 154 | Deliberate departures, called out because they cut against neighbouring |
| 155 | idioms: |
| 156 | |
| 157 | - **Replace-on-write, not create-only.** `provision.Record` is create-only |
| 158 | (409 on race, never a silent overwrite) because an instance record is a |
| 159 | one-shot fact. A migration toggle is neither one-shot nor one-way. The CAS |
| 160 | etag `getClaimETag` already carries is what keeps concurrent writes honest. |
| 161 | - **A map field, not one object per flip.** One object per account would mean |
| 162 | 10k+ bucket objects for a state that is one table in Tito's database. |
| 163 | |
| 164 | **Cap.** Mirror `maxWildcardDomains` (aliases.go:25) with `maxPathsPerClaim = |
| 165 | 10000` — deterministic drops in sorted key order, logged, never silent. |
| 166 | Unlike the wildcard cap this does not defend an external budget (there is no |
| 167 | ACME equivalent here); it bounds object size and the per-box fold. At ~40 |
| 168 | bytes an entry, 10k entries is ~400KB re-Put per flip and refolded every 2s |
| 169 | per box, which is comfortable. Sharding a host's map across objects is out of |
| 170 | scope and should not be built until a real host approaches the cap. |
| 171 | |
| 172 | --- |
| 173 | |
| 174 | ## 4. Registry: `route_paths`, wholly pass-owned |
| 175 | |
| 176 | ```sql |
| 177 | CREATE TABLE IF NOT EXISTS route_paths ( |
| 178 | host TEXT NOT NULL, |
| 179 | prefix TEXT NOT NULL, |
| 180 | target TEXT NOT NULL, |
| 181 | account TEXT NOT NULL, |
| 182 | app TEXT NOT NULL, |
| 183 | PRIMARY KEY (host, prefix) |
| 184 | ); |
| 185 | ``` |
| 186 | |
| 187 | `host TEXT PRIMARY KEY` on `routes` is what forced this to be a separate |
| 188 | table: a hostname is one row there, and it must stay one row, because that |
| 189 | row is what owns the cert, the home box, the DNS state and the aliases. |
| 190 | |
| 191 | Derivation extends `ReconcileAliases` (`internal/domains/aliases.go:48`), |
| 192 | which already folds claims into `route_aliases` and `wildcard_domains` on the |
| 193 | same pass, and already states the property this table needs: |
| 194 | |
| 195 | > *"Both tables are wholly pass-owned: every row traces to a claim, so |
| 196 | > removal needs no ownership flag."* |
| 197 | |
| 198 | Same rule here. Every `route_paths` row traces to a claim's `Paths` entry; |
| 199 | rows with no claim behind them are deleted every pass. A republished map with |
| 200 | an account removed un-flips that account on the next tick, with no delete |
| 201 | verb anywhere. |
| 202 | |
| 203 | Tenancy gates, mirroring the existing alias gates in the same loop: |
| 204 | |
| 205 | - The claim's host must resolve to a local route whose account and app match |
| 206 | the claim — the check already there for aliases. |
| 207 | - **The target must belong to the same account.** A prefix must never be able |
| 208 | to point another tenant's traffic at your backend. This is the one genuinely |
| 209 | new authorization rule in the spec and the one most worth reviewing. |
| 210 | - A target that is not a local ready route contributes no row, and logs. The |
| 211 | prefix then falls through to the host's default — a missing Go route serves |
| 212 | classic, which is the correct failure direction for a migration. |
| 213 | |
| 214 | `Route.Slug` (registry.go:36 — *"app-account short name usable on a root |
| 215 | domain. Empty = subdomain-only"*) is carried by the registry and by |
| 216 | `provision.Record` and read by nothing. It is an unwired hook for root-domain |
| 217 | path routing that someone anticipated. This spec does **not** use it: |
| 218 | `route_paths` is Tito-published desired state, while `Slug` is a property a |
| 219 | route asserts about itself, and conflating them would give two writers of one |
| 220 | routing decision. Leave it alone; if it is still dead after this ships, delete |
| 221 | it in a separate change. |
| 222 | |
| 223 | --- |
| 224 | |
| 225 | ## 5. `Fingerprint()` must fold in `route_paths` |
| 226 | |
| 227 | `Fingerprint()` (registry.go:1676) is the 2s content hash the edge polls, and |
| 228 | it hashes `routes`, `fleets`, `fleet_boxes`, `route_aliases` and |
| 229 | `wildcard_domains` — each under its own section marker so a value can never |
| 230 | migrate between sections and cancel a change out. |
| 231 | |
| 232 | Add a `route_paths` section on the same pattern: |
| 233 | |
| 234 | ```go |
| 235 | fmt.Fprint(h, "\x1droute_paths\x1e") |
| 236 | // SELECT host, prefix, target, account, app FROM route_paths ORDER BY host, prefix |
| 237 | ``` |
| 238 | |
| 239 | All five columns hash; there is no cadence-stamped column here to exclude, so |
| 240 | this table needs none of the `dns_status`/`cert_expires_at` care that |
| 241 | `route_aliases` does. |
| 242 | |
| 243 | **This is the easiest thing in the spec to omit and the worst to omit.** The |
| 244 | failure mode is silent: flips write correctly, reconcile correctly, land in |
| 245 | the table correctly, and never reach the live map — the edge keeps serving the |
| 246 | old answer until something unrelated moves the digest. The function's own |
| 247 | header already names this class of bug as the one to engineer against: |
| 248 | |
| 249 | > *"the failure mode worth engineering against is the false NEGATIVE."* |
| 250 | |
| 251 | A test that asserts the digest moves when a `route_paths` row changes is a |
| 252 | required deliverable, not a nice-to-have. |
| 253 | |
| 254 | --- |
| 255 | |
| 256 | ## 6. The edge: `lookupUpstream(host, path)` |
| 257 | |
| 258 | Host resolution is unchanged. Only when the resolved host has a prefix map |
| 259 | does a second lookup happen: |
| 260 | |
| 261 | 1. Resolve `host` exactly as today (exact, then one-label wildcard). |
| 262 | 2. If that host has no prefix map — every host on the platform today — return |
| 263 | the route. One nil map check, no allocation, no behaviour change. |
| 264 | 3. Otherwise take the first path segment — in **both** its raw and its |
| 265 | normalised spelling, see below — and look it up. A hit returns the |
| 266 | target's upstream; a miss returns the host's own route. |
| 267 | |
| 268 | **No prefix stripping.** `ti.to/paulca/awesomeconf` arrives at Tito Go as |
| 269 | `/paulca/awesomeconf`. Go already knows the account is the first segment |
| 270 | because that is how the URLs are shaped; stripping would make the flipped and |
| 271 | unflipped URL spaces differ, which is exactly what a seamless migration must |
| 272 | not do. This holds for the odd shapes too: matching normalises, **proxying |
| 273 | never does**, so the upstream always receives the request's original bytes. |
| 274 | |
| 275 | **Segment-boundary matching, on both spellings of the first segment.** Because |
| 276 | the stored prefix is a bare segment (§2), the match is a plain equality test |
| 277 | against the request's first segment — never a `strings.HasPrefix` on the raw |
| 278 | path. So the prefix `paulca` serves `/paulca` and `/paulca/awesomeconf/admin`, |
| 279 | and never `/paulcarson`. A request for `/` matches no prefix and falls through |
| 280 | to the host's own route. |
| 281 | |
| 282 | "The request's first segment" is two strings, not one, and the map is |
| 283 | consulted for both: the **raw** split of the path as it arrived, and the |
| 284 | **normalised** one (`path.Clean` first, so leading empty segments and dot |
| 285 | segments resolve). Either may hit; when both hit and they name different |
| 286 | prefixes, the normalised one wins. |
| 287 | |
| 288 | Both are needed because they fail in opposite directions, and the invariant |
| 289 | being protected is one-way — **the app an account was flipped away from must |
| 290 | never answer for that account.** Serving a not-yet-flipped account from the |
| 291 | new app is the benign direction, at worst a 404 while the migration runs. |
| 292 | |
| 293 | - **Raw alone** misses `//paulca/foo`. Most backends, Rails included, collapse |
| 294 | that to `/paulca/foo` and serve it, so the request would fall through to the |
| 295 | legacy app — which then answers for a flipped account. One character, |
| 296 | silent. |
| 297 | - **Normalised alone** misses `/paulca/../bob`, which resolves to `bob`. A |
| 298 | backend that does not collapse `..`, or that routes on the first segment |
| 299 | with a glob like `/:account/*rest`, would then serve paulca's page from the |
| 300 | legacy app. The same bypass from the other side. |
| 301 | |
| 302 | Taking a hit from either closes both. The cost is that `/paulca/../bob` |
| 303 | reaches the new app carrying `/paulca/../bob`, which a normalising target |
| 304 | resolves to `/bob` and 404s — an account it was not given. Benign, and |
| 305 | deliberate. |
| 306 | |
| 307 | **Residue**, recorded so it is not re-derived later. None of it crosses the |
| 308 | invariant above: |
| 309 | |
| 310 | - `/%2e%2e/bob` — `r.URL.Path` is already percent-decoded, so this normalises |
| 311 | to `/bob` and matches `bob`. The upstream receives the raw `/%2e%2e/bob` and |
| 312 | will most likely 404 it. Harmless. |
| 313 | - `/paulca%2ffoo` — decodes to `/paulca/foo`, so both spellings match `paulca` |
| 314 | and the request reaches the new app, while a strict backend treats the |
| 315 | upstream path as the single segment `paulca/foo`. A mis-flip toward the new |
| 316 | app: the safe direction. |
| 317 | - A prefix literally named `.` or `..` cannot exist — `validPathPrefix` |
| 318 | refuses both — so the raw spelling of `/../bob` can never match anything. |
| 319 | |
| 320 | **The scope of the guarantee**, stated so it is not over-read. The two |
| 321 | spellings model exactly two normalisations — percent-decoding and dot-segment |
| 322 | removal — and the invariant holds across every combination of a backend doing |
| 323 | or not doing those two. It is *not* a claim that no other normalisation |
| 324 | exists. A backend that folds case (`/PAULCA/foo`), strips servlet-style path |
| 325 | parameters (`/paulca;jsessionid=x/foo`), accepts backslash separators, strips |
| 326 | trailing dots, or double-decodes could resolve a path neither spelling matches |
| 327 | into a flipped account's subtree. |
| 328 | |
| 329 | Rails does none of these — Rack keeps `;` in `PATH_INFO`, and routing is |
| 330 | case-sensitive — so Tito is not exposed, which is why this is recorded rather |
| 331 | than defended against. A future consumer with different normalisation |
| 332 | behaviour needs this reasoning redone, not inherited. |
| 333 | |
| 334 | **Three call sites, and they do not all want a path** (this is the subtle part): |
| 335 | |
| 336 | - `edge.go:930` — the ServeHTTP dispatch. Takes the path. |
| 337 | - `edge.go:1455` — a `routable` check asking only *is this hostname served |
| 338 | here at all*. Must stay host-only; giving it a path would make a hostname's |
| 339 | routability depend on which URL happened to ask. |
| 340 | - `edge.go:866` — the definition. |
| 341 | |
| 342 | So the host-only form stays as the primitive and the path-aware form wraps |
| 343 | it, rather than the path-aware form replacing it everywhere. |
| 344 | |
| 345 | `ServeHTTP`'s ordering (edge.go:879) is load-bearing and documented: steering |
| 346 | probe → reserved tunnel hostname / store door → `acceptHop` → lookup → miss |
| 347 | handling → usage metering wrapping `w` exactly once. Path resolution goes |
| 348 | **inside** the existing lookup step. It must not move anything above it — |
| 349 | particularly not `acceptHop`, or a backhauled request would resolve its path |
| 350 | twice, once on each box. |
| 351 | |
| 352 | Untouched: `homemap`, `backhaul`, `certsync`, `certwarm`, `wildcard`, the |
| 353 | activator, the cache. That is the payoff of keeping the hostname as the unit |
| 354 | of placement: `ti.to` is one hostname with one home box and one cert whatever |
| 355 | its prefix map says, so none of that machinery has to learn about paths. |
| 356 | `homemap`'s lookup is documented as mirroring `lookupUpstream` *"rule for |
| 357 | rule"* — this spec keeps that true, because the rule it mirrors is the |
| 358 | host-only one. |
| 359 | |
| 360 | **But that is a statement about the HOSTNAME, not a promise that path routing |
| 361 | works across boxes. It does not.** Both halves of the resolution — the |
| 362 | reconcile pass's `byHost` and the edge's live map — are built from *this |
| 363 | box's* routes, and backhaul fires only on a **host** miss, never for a path |
| 364 | target. So the constraint is: |
| 365 | |
| 366 | > **A prefix's target route must be homed on the same box as the hostname's |
| 367 | > own route.** A target on another box derives no `route_paths` row anywhere, |
| 368 | > on any box, and every request keeps reaching the hostname's own route. |
| 369 | |
| 370 | That failure direction is safe (traffic serves the origin, never a 404) but it |
| 371 | is invisible in the traffic, so the reconcile pass logs it: one aggregated |
| 372 | line per host per pass naming the count and the first few unresolved targets, |
| 373 | distinguishing "the target does not exist" from "the target is homed |
| 374 | elsewhere" because the remedies differ. Making cross-box path targets actually |
| 375 | work needs a backhaul equivalent for path targets, which is a design change, |
| 376 | not a fix. See §11. |
| 377 | |
| 378 | **Attribution: a path hit returns the TARGET's upstream entry wholesale.** |
| 379 | Everything downstream that reads the resolved entry reads the target's route, |
| 380 | not the requested hostname's. Four consequences, three of them correct by |
| 381 | design and one a known gap: |
| 382 | |
| 383 | - **Usage metering** bills the target's route, account and app. Correct — the |
| 384 | target's backend burned the bytes. Pinned by a test so a later "fix" in the |
| 385 | other direction is caught. |
| 386 | - **The `X-Carlos-Version` header** reports the target's release. Correct — it |
| 387 | names the code that produced the response. |
| 388 | - **Activator keying and hibernation** are the target's, so a flipped prefix |
| 389 | wakes the target instance. Correct — that is the process that has to be |
| 390 | awake. |
| 391 | - **Purge tenancy** is the target's, while the response **cache keys on the |
| 392 | requested hostname**. That mismatch is a real gap; §11 records it and its |
| 393 | bound. |
| 394 | |
| 395 | --- |
| 396 | |
| 397 | ## 7. The write door |
| 398 | |
| 399 | An earlier revision of this design claimed a route write API was net-new |
| 400 | work, because `internal/console/apiroutes.go` is read-only. That was looking |
| 401 | in the wrong place. The claim API already exists, with service-role scoping |
| 402 | (`internal/console/authcli.go:618-624`): |
| 403 | |
| 404 | ``` |
| 405 | GET /api/cli/apps/{account}/{app}/domains |
| 406 | POST /api/cli/apps/{account}/{app}/domains |
| 407 | GET /api/cli/apps/{account}/{app}/domains/{host} |
| 408 | DELETE /api/cli/apps/{account}/{app}/domains/{host} |
| 409 | ``` |
| 410 | |
| 411 | Tito's door is a sibling: |
| 412 | |
| 413 | ``` |
| 414 | PUT /api/cli/apps/{account}/{app}/domains/{host}/paths |
| 415 | ``` |
| 416 | |
| 417 | **Wire shape.** The body is the whole map, WRAPPED in a `paths` field — not a |
| 418 | bare map: |
| 419 | |
| 420 | ```json |
| 421 | { "paths": { "paulca": "go.tito.io", "bigcorp": "go.tito.io" } } |
| 422 | ``` |
| 423 | |
| 424 | `{"paths": {}}` is how a map is cleared. A bare map, or a body whose `paths` |
| 425 | field is absent or misspelled, answers **400**. The wrapper is what makes that |
| 426 | possible: `paths` is decoded into a POINTER, so "absent" and "empty" are |
| 427 | distinguishable, and a typo in the field name becomes a refusal instead of a |
| 428 | silent wholesale clear of every flip on the hostname. (An earlier revision of |
| 429 | this spec said "body is the whole map", which would fail on the first call — |
| 430 | corrected here.) |
| 431 | |
| 432 | It reuses the existing auth, account scoping and service roles wholesale, and |
| 433 | its handler is the shape of the capability handler at apidomains.go:482: read |
| 434 | claim under etag, replace one field, CAS write. |
| 435 | |
| 436 | **Roles: `Publish` AND `Instance`.** The first implementation made this |
| 437 | `Publish` only, reasoning that deciding whose traffic leaves the legacy app is |
| 438 | a deployment-shaped act. Review overturned that on internal coherence: the |
| 439 | paths *read* (`GET .../domains/{host}`) is gated `Publish+Instance`, so |
| 440 | `Publish`-only left an Instance credential able to read a hostname's published |
| 441 | map and not write it — and `apidomains.go`'s own file header names Tito's |
| 442 | router, calling this family with its `CARLOS_INSTANCE_TOKEN`, as the caller |
| 443 | the door exists for. Widening crosses no tenancy boundary: the door is |
| 444 | owner-gated on account and app, so an Instance token can only ever publish its |
| 445 | own app's map, and the reconcile pass re-checks every target against the same |
| 446 | account regardless. Recorded here so the scoping is a visible choice rather |
| 447 | than an accident. |
| 448 | |
| 449 | **`refuseServiceControlPlane`.** A SERVICE credential of any role is refused |
| 450 | outright when the app is the deployment's own control plane; a person is not. |
| 451 | A prefix map on the console's own hostname would hand a slice of the console's |
| 452 | URL space (`/admin`, `/api`) to whatever else that account runs, and that is |
| 453 | not a decision a CI or router credential should be able to take. The guard |
| 454 | sits before the hostname is normalized, so its answer cannot vary with host |
| 455 | shape — a flagged app refuses a malformed host and a valid one with the |
| 456 | identical status and body. |
| 457 | |
| 458 | Following `SetClaimCapabilities`' precedent (claims.go:279, *"the ONLY writer |
| 459 | of a claim's Wildcard/Catchall fields"*), a single `SetClaimPaths` is the only |
| 460 | writer of `Paths`, so `AttachClaim`'s converge can neither invent nor strip |
| 461 | the map. |
| 462 | |
| 463 | **Flip latency, end to end:** `PUT` → bucket → **serial bump** → |
| 464 | fast-converge tick (`fastTickEvery = 2s` plus jitter, agent.go:1053/1504) → |
| 465 | registry write → 2s fingerprint poll → live map. **Under about five |
| 466 | seconds**, which is inside what a Tito admin toggle can plausibly wait on. |
| 467 | |
| 468 | The serial bump is not incidental, and this document's first revision |
| 469 | omitted it — the omission was caught during implementation. Without it the |
| 470 | write lands in the bucket correctly and then waits for the *hourly* pass, |
| 471 | turning a five-second flip into an hour-long one. Attach and detach already |
| 472 | bump for exactly this reason, so the door must do what its siblings do: |
| 473 | write, then audit, then bump, all inside the `changed` branch so a no-op |
| 474 | republish costs neither an audit row nor a fleet-wide reload. |
| 475 | |
| 476 | The audit row matters independently of latency. A prefix map decides where |
| 477 | an account's traffic goes, so "who moved this hostname's traffic, and when" |
| 478 | has to be answerable afterwards. The row records actor, account, app, host |
| 479 | and the prefix count — deliberately not the targets themselves, since the |
| 480 | map may hold ten thousand entries. |
| 481 | |
| 482 | --- |
| 483 | |
| 484 | ## 8. Surfaces |
| 485 | |
| 486 | - `carlos domains paths <host>` — list a host's prefix map. |
| 487 | - `carlos routes` gains path rows, indented under their host, so the flipped |
| 488 | set is visible from the same command operators already use. |
| 489 | - Both read-only. Tito is the writer; a CLI write verb would create a second |
| 490 | writer of state whose source of truth is Tito's database, and the two would |
| 491 | drift the first time anyone used it. |
| 492 | |
| 493 | --- |
| 494 | |
| 495 | ## 9. What this does NOT do |
| 496 | |
| 497 | - **No regex, globs, or multi-segment prefixes.** First segment, exact match. |
| 498 | Tito's URL shape is `/<account>/...` and nothing here should outgrow that |
| 499 | before there is a second consumer to generalise against. |
| 500 | - **No reserved-prefix list.** `ti.to/login` and `ti.to/assets` must not be |
| 501 | shadowed by an account slug — but Tito already guarantees that, because |
| 502 | those names cannot be registered as accounts. Duplicating the list in CARLOS |
| 503 | gives two places to update and one to forget. If this proves wrong, it is a |
| 504 | small additive change to the reconcile gate. |
| 505 | - **No per-prefix channel or environment.** Point the prefix at a route that |
| 506 | has them (§2). |
| 507 | - **No path-aware certs, DNS, or placement.** A hostname remains the unit of |
| 508 | all three. |
| 509 | - **No `ti.to` migration.** Explicitly out of scope per Paul's ruling. |
| 510 | |
| 511 | --- |
| 512 | |
| 513 | ## 10. Rollout, and how this is proven without `ti.to` |
| 514 | |
| 515 | The honest risk of building machinery with no consumer is that it ships |
| 516 | looking finished and is wrong in a way nobody discovers for months. So the |
| 517 | acceptance bar is an end-to-end flip on a real hostname, not unit tests. |
| 518 | |
| 519 | 1. **Table, wire field, fingerprint section.** Additive; zero rows on every |
| 520 | existing deployment; no behaviour change. The fingerprint test from §5 |
| 521 | lands here. |
| 522 | 2. **Reconcile derivation + tenancy gates**, including a test that a target |
| 523 | in another account is refused. |
| 524 | 3. **`lookupUpstream(host, path)`** plus the three call sites, with the |
| 525 | `edge.go:1455` host-only form preserved. |
| 526 | 4. **The write door and `SetClaimPaths`.** |
| 527 | 5. **End-to-end proof on a scratch host.** Two trivial apps, `A` and `B`, on |
| 528 | one hostname: `scratch.example/b` reaches `B`, everything else reaches `A`, |
| 529 | `/bat` reaches `A` (boundary), removing the entry from a republished map |
| 530 | restores `A` within ~5s. This is the deliverable that says the machinery |
| 531 | works — everything before it is scaffolding. |
| 532 | |
| 533 | Stages 1-4 are independently landable PRs. Nothing is observable until 5. |
| 534 | |
| 535 | --- |
| 536 | |
| 537 | ## 11. Live checks owed, and known gaps |
| 538 | |
| 539 | None is a blocker for stages 1-5; all are blockers before Tito relies on |
| 540 | any of it. |
| 541 | |
| 542 | - **Case 2, end to end.** Can `*.tito.page` be created through a supported |
| 543 | door? `provision.ValidHostShape` (provision.go:250) checks every label |
| 544 | against `labelRe`, which a literal `*` cannot pass — so wildcard rows arrive |
| 545 | by some other route (a `Wildcard`+`Catchall` claim, per aliases.go, is the |
| 546 | likely one). And the delegated wildcard cert path needs proving with a real |
| 547 | handshake. This is read-from-code, not observed. **Case 2 is the case Paul |
| 548 | can act on soonest, and it is the one with the least evidence behind it.** |
| 549 | - **`ti.to` behind the CARLOS edge.** Out of scope here, but it is the |
| 550 | prerequisite for case 1 ever being used, and it is the largest single risk |
| 551 | in the whole migration. Worth its own spec when Paul is ready. |
| 552 | |
| 553 | - **The production converge loop actually folds a path claim on schedule.** |
| 554 | §10's end-to-end test drives `ReconcileAliases` and `Edge.Reload` directly, |
| 555 | which is the right call — the 2s tick is a latency bound, not a correctness |
| 556 | property, and polling it would make the fastest test in the plan slow and |
| 557 | flaky. But that means the test proves the layers compose *given that |
| 558 | something calls those two functions*. It does not prove that the real |
| 559 | converge loop (`internal/host/agent.go:1475` and `:1955`, the two |
| 560 | production call sites of `ReconcileAliases`) invokes them for a live path |
| 561 | claim on the real schedule. |
| 562 | |
| 563 | The general fingerprint→reload mechanism is not unproven — `Edge.Watch` is |
| 564 | exercised against a live goroutine with bounded polling elsewhere in |
| 565 | `internal/edge`. What is unproven is specifically that a published `paths` |
| 566 | map traverses the real host agent's timing end to end. That is an |
| 567 | integration concern a package-level test cannot absorb, so it belongs here |
| 568 | rather than in the suite. **It is also the check that would falsify §7's |
| 569 | "under about five seconds" claim if that claim is wrong**, which makes it |
| 570 | the most load-bearing of the three items in this section. |
| 571 | |
| 572 | - **Cross-box path targets.** Both halves of the path resolution read this |
| 573 | box's routes only, and there is no backhaul equivalent for a path target |
| 574 | (§6). `ti.to` on one box and `go.tito.io` on another — the likely shape for |
| 575 | two apps as different as Tito classic and Tito Go — derives no rows and |
| 576 | routes nothing, forever. The reconcile pass now says so in the log, but |
| 577 | nothing enforces the constraint at the write door and nothing has ever been |
| 578 | observed. Before any real flip: confirm the hostname's route and every |
| 579 | target route are homed on the same box, and decide whether making it work |
| 580 | across boxes is wanted (a design change, not a fix). |
| 581 | |
| 582 | **Resolved: a path-routed app purging its own cache.** Shipped as a known gap |
| 583 | and closed straight after, once it became clear the platform might carry more |
| 584 | than a two-app migration. |
| 585 | |
| 586 | The problem was real. The response cache keys on the REQUESTED hostname |
| 587 | (`ti.to`), while `purgeModifier` is built from the resolved route — the |
| 588 | TARGET's (`go.tito.io`). So a bare `Carlos-Purge` from the Go app purged under |
| 589 | `go.tito.io` and dropped nothing, because the entry lives under `ti.to`. The |
| 590 | documented `Carlos-Purge-Host: ti.to` escape hatch did not help either: |
| 591 | `resolvePurgeHost` required the override to name a host in the same app, and |
| 592 | path routing exists precisely to cross an app boundary. Aliases never hit |
| 593 | this, because an alias maps to the primary's own route and the app comparison |
| 594 | passes trivially. |
| 595 | |
| 596 | `resolvePurgeHost` now accepts a cross-app override when a prefix on the named |
| 597 | host selects the serving route. That grants nothing new: the edge is already |
| 598 | routing that host's traffic here, so this route serves pages under that |
| 599 | hostname. **Account is still required**, and is checked at the edge rather than |
| 600 | inherited from the reconcile pass — a path row written by an older binary or by |
| 601 | hand must not reach another tenant's cache. The exception is scoped to the |
| 602 | route the prefix map actually names, so "the front host path-routes somewhere" |
| 603 | is not enough. |
| 604 | |
| 605 | **What an app must do:** emit `Carlos-Purge-Host` set to the `Host` header it |
| 606 | received, unconditionally. The inbound Host is preserved end to end, so on an |
| 607 | ordinary request that equals the serving route's own host and takes the |
| 608 | existing own-host branch; on a path-routed one it names the front hostname and |
| 609 | takes the new branch. One line of shared code, and nothing in the app needs to |
| 610 | know whether it is path-routed. |
| 611 | |