carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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),
62026-08-27-managed-custom-domains-design.md (the claim → pass-owned-table
7idiom this spec extends), 2026-08-20-multi-region-ingress-design.md (the 2s
8fingerprint poll this spec must not bypass).
9
10## 0. How this spec was arrived at
11
12Four 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
40account page, `ti.to/paulca/awesomeconf` the event, and
41`ti.to/paulca/awesomeconf/admin` the admin. The first path segment is the
42account, and it is the flip discriminator. Flipping one account must move
43that account's whole subtree to Tito Go, reversibly, without touching the
44other accounts. **This needs new code.**
45
46**Case 2 — Tito Pro on `*.tito.page`, host-based.** Pro customers each get a
47CNAME like `deutsche-filmakademie.tito.page`. Flipping one means pointing
48that hostname at Tito Go.
49
50Case 2 needs **no new routing code**. `lookupUpstream` (edge.go:866) tries an
51exact host match before the one-label wildcard fallback, and that ordering is
52a documented guarantee, not an accident:
53
54```go
55func (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
69So 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
71flip today. Rollback is deleting the exact row. What is *not* yet proven is
72the operational path to that state — see §11.
73
74The 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
80The obvious design gives each prefix its own upstream, channel and
81environment. That is wrong: it duplicates a route's entire configuration per
82account, and Tito already has both backends as real routes.
83
84Instead, mirror `route_aliases`, which already works exactly this way — an
85alias is a *pointer* to a primary host, and the primary supplies everything
86else. A path route is that same pointer with a prefix attached:
87
88```
89route_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
96A 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
98one spelling and no normalisation step to get wrong. Surfaces may render it
99with a slash for readability; nothing else adds one.
100
101That 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
103chooses, so the matcher does normalise it — see §6, which describes what it
104normalises and why one canonical form is not enough. The two are separate
105problems: 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
108probe, hibernation policy and usage metering. The row that points at it
109carries no upstream, no channel and no environment of its own. Nothing is
110duplicated, and every existing subsystem keeps working because the thing
111being selected is an ordinary route.
112
113This also buys something that was not asked for and costs nothing:
114**canarying a subset of accounts.** Point most flipped accounts at the stable
115Go route and a handful at a second Go route bound to a beta channel. It falls
116out of the model; there is no special case for it.
117
118**Migration progress is the row count.** `route_paths` holds only flipped
119accounts. Rollback for one account is one row removed. When everyone is
120across, 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
126The control-plane object already exists. A claim (`internal/domains/claims.go:34`)
127lives at `domains/<host>.json` — **one object per hostname**, which is exactly
128the 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
130registry on the fast-converge leg every ~2s.
131
132It 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
137So 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.
145Paths map[string]string `json:"paths,omitempty"`
146```
147
148**Tito publishes this wholesale, regenerated from its own database.** Not
149deltas. A lost delta desyncs the two systems silently and nobody finds out
150until a customer reports a wrong page; a wholesale republish is a full
151repair, and republishing is always safe. One `PUT` per flip regardless of how
152many accounts are in the map.
153
154Deliberate departures, called out because they cut against neighbouring
155idioms:
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 =
16510000` — deterministic drops in sorted key order, logged, never silent.
166Unlike the wildcard cap this does not defend an external budget (there is no
167ACME equivalent here); it bounds object size and the per-box fold. At ~40
168bytes an entry, 10k entries is ~400KB re-Put per flip and refolded every 2s
169per box, which is comfortable. Sharding a host's map across objects is out of
170scope 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
177CREATE 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
188table: a hostname is one row there, and it must stay one row, because that
189row is what owns the cert, the home box, the DNS state and the aliases.
190
191Derivation extends `ReconcileAliases` (`internal/domains/aliases.go:48`),
192which already folds claims into `route_aliases` and `wildcard_domains` on the
193same 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
198Same rule here. Every `route_paths` row traces to a claim's `Paths` entry;
199rows with no claim behind them are deleted every pass. A republished map with
200an account removed un-flips that account on the next tick, with no delete
201verb anywhere.
202
203Tenancy 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
215domain. 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
217path routing that someone anticipated. This spec does **not** use it:
218`route_paths` is Tito-published desired state, while `Slug` is a property a
219route asserts about itself, and conflating them would give two writers of one
220routing decision. Leave it alone; if it is still dead after this ships, delete
221it 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
228it hashes `routes`, `fleets`, `fleet_boxes`, `route_aliases` and
229`wildcard_domains` — each under its own section marker so a value can never
230migrate between sections and cancel a change out.
231
232Add a `route_paths` section on the same pattern:
233
234```go
235fmt.Fprint(h, "\x1droute_paths\x1e")
236// SELECT host, prefix, target, account, app FROM route_paths ORDER BY host, prefix
237```
238
239All five columns hash; there is no cadence-stamped column here to exclude, so
240this 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
244failure mode is silent: flips write correctly, reconcile correctly, land in
245the table correctly, and never reach the live map — the edge keeps serving the
246old answer until something unrelated moves the digest. The function's own
247header 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
251A test that asserts the digest moves when a `route_paths` row changes is a
252required deliverable, not a nice-to-have.
253
254---
255
256## 6. The edge: `lookupUpstream(host, path)`
257
258Host resolution is unchanged. Only when the resolved host has a prefix map
259does a second lookup happen:
260
2611. Resolve `host` exactly as today (exact, then one-label wildcard).
2622. 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.
2643. 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
270because that is how the URLs are shaped; stripping would make the flipped and
271unflipped URL spaces differ, which is exactly what a seamless migration must
272not do. This holds for the odd shapes too: matching normalises, **proxying
273never does**, so the upstream always receives the request's original bytes.
274
275**Segment-boundary matching, on both spellings of the first segment.** Because
276the stored prefix is a bare segment (§2), the match is a plain equality test
277against the request's first segment — never a `strings.HasPrefix` on the raw
278path. So the prefix `paulca` serves `/paulca` and `/paulca/awesomeconf/admin`,
279and never `/paulcarson`. A request for `/` matches no prefix and falls through
280to the host's own route.
281
282"The request's first segment" is two strings, not one, and the map is
283consulted 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
285segments resolve). Either may hit; when both hit and they name different
286prefixes, the normalised one wins.
287
288Both are needed because they fail in opposite directions, and the invariant
289being protected is one-way — **the app an account was flipped away from must
290never answer for that account.** Serving a not-yet-flipped account from the
291new 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
302Taking a hit from either closes both. The cost is that `/paulca/../bob`
303reaches the new app carrying `/paulca/../bob`, which a normalising target
304resolves to `/bob` and 404s — an account it was not given. Benign, and
305deliberate.
306
307**Residue**, recorded so it is not re-derived later. None of it crosses the
308invariant 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
321spellings model exactly two normalisations — percent-decoding and dot-segment
322removal — and the invariant holds across every combination of a backend doing
323or not doing those two. It is *not* a claim that no other normalisation
324exists. A backend that folds case (`/PAULCA/foo`), strips servlet-style path
325parameters (`/paulca;jsessionid=x/foo`), accepts backslash separators, strips
326trailing dots, or double-decodes could resolve a path neither spelling matches
327into a flipped account's subtree.
328
329Rails does none of these — Rack keeps `;` in `PATH_INFO`, and routing is
330case-sensitive — so Tito is not exposed, which is why this is recorded rather
331than defended against. A future consumer with different normalisation
332behaviour 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
342So the host-only form stays as the primitive and the path-aware form wraps
343it, rather than the path-aware form replacing it everywhere.
344
345`ServeHTTP`'s ordering (edge.go:879) is load-bearing and documented: steering
346probe → reserved tunnel hostname / store door → `acceptHop` → lookup → miss
347handling → usage metering wrapping `w` exactly once. Path resolution goes
348**inside** the existing lookup step. It must not move anything above it —
349particularly not `acceptHop`, or a backhauled request would resolve its path
350twice, once on each box.
351
352Untouched: `homemap`, `backhaul`, `certsync`, `certwarm`, `wildcard`, the
353activator, the cache. That is the payoff of keeping the hostname as the unit
354of placement: `ti.to` is one hostname with one home box and one cert whatever
355its prefix map says, so none of that machinery has to learn about paths.
356`homemap`'s lookup is documented as mirroring `lookupUpstream` *"rule for
357rule"* — this spec keeps that true, because the rule it mirrors is the
358host-only one.
359
360**But that is a statement about the HOSTNAME, not a promise that path routing
361works across boxes. It does not.** Both halves of the resolution — the
362reconcile pass's `byHost` and the edge's live map — are built from *this
363box's* routes, and backhaul fires only on a **host** miss, never for a path
364target. 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
370That failure direction is safe (traffic serves the origin, never a 404) but it
371is invisible in the traffic, so the reconcile pass logs it: one aggregated
372line per host per pass naming the count and the first few unresolved targets,
373distinguishing "the target does not exist" from "the target is homed
374elsewhere" because the remedies differ. Making cross-box path targets actually
375work needs a backhaul equivalent for path targets, which is a design change,
376not a fix. See §11.
377
378**Attribution: a path hit returns the TARGET's upstream entry wholesale.**
379Everything downstream that reads the resolved entry reads the target's route,
380not the requested hostname's. Four consequences, three of them correct by
381design 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
399An earlier revision of this design claimed a route write API was net-new
400work, because `internal/console/apiroutes.go` is read-only. That was looking
401in the wrong place. The claim API already exists, with service-role scoping
402(`internal/console/authcli.go:618-624`):
403
404```
405GET /api/cli/apps/{account}/{app}/domains
406POST /api/cli/apps/{account}/{app}/domains
407GET /api/cli/apps/{account}/{app}/domains/{host}
408DELETE /api/cli/apps/{account}/{app}/domains/{host}
409```
410
411Tito's door is a sibling:
412
413```
414PUT /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
418bare 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`
425field is absent or misspelled, answers **400**. The wrapper is what makes that
426possible: `paths` is decoded into a POINTER, so "absent" and "empty" are
427distinguishable, and a typo in the field name becomes a refusal instead of a
428silent wholesale clear of every flip on the hostname. (An earlier revision of
429this spec said "body is the whole map", which would fail on the first call —
430corrected here.)
431
432It reuses the existing auth, account scoping and service roles wholesale, and
433its handler is the shape of the capability handler at apidomains.go:482: read
434claim 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
438a deployment-shaped act. Review overturned that on internal coherence: the
439paths *read* (`GET .../domains/{host}`) is gated `Publish+Instance`, so
440`Publish`-only left an Instance credential able to read a hostname's published
441map and not write it — and `apidomains.go`'s own file header names Tito's
442router, calling this family with its `CARLOS_INSTANCE_TOKEN`, as the caller
443the door exists for. Widening crosses no tenancy boundary: the door is
444owner-gated on account and app, so an Instance token can only ever publish its
445own app's map, and the reconcile pass re-checks every target against the same
446account regardless. Recorded here so the scoping is a visible choice rather
447than an accident.
448
449**`refuseServiceControlPlane`.** A SERVICE credential of any role is refused
450outright when the app is the deployment's own control plane; a person is not.
451A prefix map on the console's own hostname would hand a slice of the console's
452URL space (`/admin`, `/api`) to whatever else that account runs, and that is
453not a decision a CI or router credential should be able to take. The guard
454sits before the hostname is normalized, so its answer cannot vary with host
455shape — a flagged app refuses a malformed host and a valid one with the
456identical status and body.
457
458Following `SetClaimCapabilities`' precedent (claims.go:279, *"the ONLY writer
459of a claim's Wildcard/Catchall fields"*), a single `SetClaimPaths` is the only
460writer of `Paths`, so `AttachClaim`'s converge can neither invent nor strip
461the map.
462
463**Flip latency, end to end:** `PUT` → bucket → **serial bump** →
464fast-converge tick (`fastTickEvery = 2s` plus jitter, agent.go:1053/1504) →
465registry write → 2s fingerprint poll → live map. **Under about five
466seconds**, which is inside what a Tito admin toggle can plausibly wait on.
467
468The serial bump is not incidental, and this document's first revision
469omitted it — the omission was caught during implementation. Without it the
470write lands in the bucket correctly and then waits for the *hourly* pass,
471turning a five-second flip into an hour-long one. Attach and detach already
472bump for exactly this reason, so the door must do what its siblings do:
473write, then audit, then bump, all inside the `changed` branch so a no-op
474republish costs neither an audit row nor a fleet-wide reload.
475
476The audit row matters independently of latency. A prefix map decides where
477an account's traffic goes, so "who moved this hostname's traffic, and when"
478has to be answerable afterwards. The row records actor, account, app, host
479and the prefix count — deliberately not the targets themselves, since the
480map 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
515The honest risk of building machinery with no consumer is that it ships
516looking finished and is wrong in a way nobody discovers for months. So the
517acceptance bar is an end-to-end flip on a real hostname, not unit tests.
518
5191. **Table, wire field, fingerprint section.** Additive; zero rows on every
520 existing deployment; no behaviour change. The fingerprint test from §5
521 lands here.
5222. **Reconcile derivation + tenancy gates**, including a test that a target
523 in another account is refused.
5243. **`lookupUpstream(host, path)`** plus the three call sites, with the
525 `edge.go:1455` host-only form preserved.
5264. **The write door and `SetClaimPaths`.**
5275. **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
533Stages 1-4 are independently landable PRs. Nothing is observable until 5.
534
535---
536
537## 11. Live checks owed, and known gaps
538
539None is a blocker for stages 1-5; all are blockers before Tito relies on
540any 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
583and closed straight after, once it became clear the platform might carry more
584than a two-app migration.
585
586The problem was real. The response cache keys on the REQUESTED hostname
587(`ti.to`), while `purgeModifier` is built from the resolved route — the
588TARGET'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
590documented `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
592path routing exists precisely to cross an app boundary. Aliases never hit
593this, because an alias maps to the primary's own route and the app comparison
594passes trivially.
595
596`resolvePurgeHost` now accepts a cross-app override when a prefix on the named
597host selects the serving route. That grants nothing new: the edge is already
598routing that host's traffic here, so this route serves pages under that
599hostname. **Account is still required**, and is checked at the edge rather than
600inherited from the reconcile pass — a path row written by an older binary or by
601hand must not reach another tenant's cache. The exception is scoped to the
602route the prefix map actually names, so "the front host path-routes somewhere"
603is not enough.
604
605**What an app must do:** emit `Carlos-Purge-Host` set to the `Host` header it
606received, unconditionally. The inbound Host is preserved end to end, so on an
607ordinary request that equals the serving route's own host and takes the
608existing own-host branch; on a path-routed one it names the front hostname and
609takes the new branch. One line of shared code, and nothing in the app needs to
610know whether it is path-routed.
611