carlos / platform Public

edge: let a path-routed app purge its own cache

The response cache keys on the hostname the browser asked for, while
purgeModifier is built from the route that served the request. For a
path-routed request those are different hosts, so a bare Carlos-Purge
from the serving app purged under its own hostname and dropped nothing —
the entry lives under the front host.

The documented escape hatch, naming the front host explicitly, was
refused too: 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 here
rather than inherited from the reconcile pass that writes path rows — a
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.

An app can now emit Carlos-Purge-Host set to the Host header it
received, unconditionally: on an ordinary request that equals the
serving route's own host and takes the existing branch, and on a
path-routed one it names the front host. Nothing in the app has to know
which it is.

Shipped as a known gap in the path-routing spec and closed here, since
the platform may end up carrying more than a two-app migration.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DJMVUDxWsDp4HPaYEU1Ma
Paul Campbell pushed by paul@keymail.dev 236f2f8e29f929b3febc71939f247b7d8be30a2a parent 6a59497
4 files changed, +259 −34
  • docs/superpowers/specs/2026-09-02-path-based-routing-design.md +29 −16
  • internal/edge/edge.go +19 −16
  • internal/edge/purge_header.go +55 −2
  • internal/edge/purge_path_test.go +156 −0
diff --git a/docs/superpowers/specs/2026-09-02-path-based-routing-design.md b/docs/superpowers/specs/2026-09-02-path-based-routing-design.md
index ea44193e0..c20c1375c 100644
--- a/docs/superpowers/specs/2026-09-02-path-based-routing-design.md
+++ b/docs/superpowers/specs/2026-09-02-path-based-routing-design.md
@@ -579,19 +579,32 @@ any of it.
target route are homed on the same box, and decide whether making it work
across boxes is wanted (a design change, not a fix).
-**Known gap: a path-routed app cannot purge its own cache.** 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 `Carlos-Purge`
-emitted by the Go app purges under `go.tito.io` and drops nothing; the entry
-lives under `ti.to`. The documented `Carlos-Purge-Host: ti.to` escape hatch
-does not help either: `resolvePurgeHost` requires 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.
-
-The bound: a path-routed response can go stale for at most the cache's max
-TTL, and only for responses that opted into caching with a positive
-`max-age`. Uncached responses are unaffected. Deliberately NOT fixed here —
-changing the purge tenancy model is unreviewed territory this feature did not
-design. Documented on `lookupUpstreamPath` and recorded here so a consumer
-knows before it relies on purge.
+**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.
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index 5d3ee5b39..4d86d5886 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -952,28 +952,31 @@ func (e *Edge) lookupUpstream(host string) (upstream, bool) {
// 2. The X-Carlos-Version response header reports the TARGET's release.
// Correct: it names the code that produced the response.
// 3. Purge tenancy is the TARGET's app and account (purgeModifier is built
-// from up.route). Correct as tenancy — but it is also a real GAP, see
-// below.
+// from up.route), while the response CACHE keys on the REQUESTED host.
+// Those two disagree, and closing that is what the path-routing branch
+// in resolvePurgeHost exists for — see below.
// 4. Activator keying and hibernation are the TARGET's (up.hibernating and
// up.route come from the same entry), so a request for a flipped prefix
// wakes the target instance, not the requested hostname's. Correct: the
// target is the process that has to be awake to answer.
//
-// The gap in (3): the response CACHE keys on the REQUESTED host
-// (CacheKey{Host: host} in ServeHTTP), while purgeModifier resolves the purge
-// host from the target's route. So a path-routed app emitting Carlos-Purge
-// purges under go.tito.io while its entry is stored under ti.to, and nothing
-// is dropped; the documented Carlos-Purge-Host: ti.to escape hatch does not
-// help either, because resolvePurgeHost requires the override to name a host
-// in the SAME app — and path routing exists precisely to cross an app
-// boundary. Aliases never hit this: an alias key maps to the primary's own
-// route, so the app comparison passes trivially.
+// How (3) is closed, and what an app must do. The cache keys on the requested
+// host (CacheKey{Host: host} in ServeHTTP) while purgeModifier is built from
+// the target's route, so a path-routed app emitting a bare Carlos-Purge still
+// purges under go.tito.io while its entry is stored under ti.to, and drops
+// nothing. The fix is the documented escape hatch: name the hostname the
+// request arrived on, Carlos-Purge-Host: ti.to. resolvePurgeHost used to
+// refuse that — it required the override to name a host in the SAME app, and
+// path routing exists precisely to cross an app boundary — and now allows it
+// when a prefix on the named host selects this route. Account is still
+// required. Aliases never hit any of this: an alias key maps to the primary's
+// own route, so the app comparison passes trivially.
//
-// The bound on that gap: a path-routed response can go stale for at most the
-// cache's max TTL, and only for responses that opted into caching with a
-// positive max-age. Uncached responses are unaffected. Widening the purge
-// tenancy model to follow a prefix map is a design change this feature did
-// not make; it is recorded as a known gap in design spec §11.
+// So an app can emit Carlos-Purge-Host set to the Host header it received,
+// unconditionally, from shared code. The inbound Host is preserved end to end,
+// so on an ordinary request that equals rt.Host and takes resolvePurgeHost's
+// own-host branch; on a path-routed one it names the front hostname and takes
+// the path-routing branch. Nothing in the app has to know which it is.
func (e *Edge) lookupUpstreamPath(host, urlPath string) (upstream, bool) {
m := e.current()
up, ok := lookupIn(m, host)
diff --git a/internal/edge/purge_header.go b/internal/edge/purge_header.go
index bdb75c250..1d8830132 100644
--- a/internal/edge/purge_header.go
+++ b/internal/edge/purge_header.go
@@ -389,16 +389,69 @@ func (e *Edge) resolvePurgeHost(rt registry.Route, declared []string) (string, b
// everywhere else on this box. A raw comparison never lets a purge cross a
// real account boundary, but it does split one account in two and refuse a
// purge between two routes that demonstrably share one key space.
+ // The path-routing exception, and the ONLY place an override crosses an
+ // app boundary.
+ //
+ // When host splits its traffic by path (route_paths) and one of its
+ // prefixes selects THIS route, the edge is already sending some of host's
+ // requests here — and caching the responses under host, because the cache
+ // key is the hostname the browser asked for while this proxy was built
+ // from the target's route. Without this branch the serving app cannot
+ // purge pages it is itself producing: its own Carlos-Purge lands under
+ // rt.Host and hits nothing, and naming host explicitly is refused by the
+ // App check below, because path routing exists precisely to cross an app
+ // boundary (Tito classic and Tito Go are two apps).
+ //
+ // Nothing is granted that is not already true: the edge demonstrably
+ // routes host's traffic to this route, so this route serves pages under
+ // that hostname.
+ //
+ // ACCOUNT is still required, and is checked HERE rather than inherited
+ // from the reconcile pass that writes path rows. That pass does enforce
+ // same-account on a target, but a row written by an older binary, or by
+ // hand, must not be able to reach another tenant's cache — the same
+ // defence-in-depth reasoning the staleness re-read above applies to the
+ // serving side.
+ //
+ // Scoped to the route the prefix map actually names: "host path-routes
+ // somewhere" is not enough, or any app in the account could purge host by
+ // naming it.
+ if rt.App != "" && pathRoutesTo(up, rt.Host) &&
+ release.RouteAccount(up.route) == release.RouteAccount(rt) {
+ return host, true
+ }
+ // App AND Account, both, and App must be a real one: a route with no App
+ // has no releases and no provable tenancy, so ""=="" is not a match — two
+ // unrelated App-less routes on one box must not be able to purge each
+ // other.
if rt.App == "" || up.route.App != rt.App ||
release.RouteAccount(up.route) != release.RouteAccount(rt) {
e.logPurgeIssue("override-tenancy", rt.Host, fmt.Sprintf(
- "%s (app %q, account %q) declared Carlos-Purge-Host %q, which belongs to app %q, account %q — a purge never crosses an app or account boundary; the whole declaration is ignored",
- rt.Host, rt.App, rt.Account, truncateForLog(host), up.route.App, up.route.Account))
+ "%s (app %q, account %q) declared Carlos-Purge-Host %q, which belongs to app %q, account %q — a purge crosses an app boundary only when %q path-routes to the serving route, and never crosses an account boundary; the whole declaration is ignored",
+ rt.Host, rt.App, rt.Account, truncateForLog(host), up.route.App, up.route.Account, truncateForLog(host)))
return "", false
}
return host, true
}
+// pathRoutesTo reports whether any prefix on up selects the route at target.
+//
+// A linear scan of the prefix map, deliberately, rather than a reverse index
+// folded at Reload. It runs only for a response that declares a cross-host
+// Carlos-Purge-Host — rare — and the map is capped at
+// domains.MaxPathsPerClaim, so the worst case is a few thousand string
+// compares on a path that already did a cache write. An index would cost a
+// field on every upstream and fold work on every reload to save that; revisit
+// if purge-declaring responses ever become the common case.
+func pathRoutesTo(up upstream, target string) bool {
+ for _, t := range up.paths {
+ if t == target {
+ return true
+ }
+ }
+ return false
+}
+
// validPurgeOverrideHost reports whether h is shaped like a host key in the
// edge's routing table: a bare hostname, nothing else.
//
diff --git a/internal/edge/purge_path_test.go b/internal/edge/purge_path_test.go
new file mode 100644
index 000000000..29d4d6b4d
--- /dev/null
+++ b/internal/edge/purge_path_test.go
@@ -0,0 +1,156 @@
+package edge
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// purgePathEdge stands up the shape path routing exists to create: a front
+// hostname whose prefix routes to a second route belonging to a DIFFERENT app
+// in the same account, plus a cache. The path row is written before New so
+// Reload folds it into the upstream map.
+func purgePathEdge(t *testing.T, front, target registry.Route, prefix string, h http.HandlerFunc) *Edge {
+ t.Helper()
+ backend := httptest.NewServer(h)
+ t.Cleanup(backend.Close)
+ reg := openReg(t)
+ for _, rt := range []registry.Route{front, target} {
+ rt.Kind, rt.Addr, rt.Status = "instance", backend.Listener.Addr().String(), "ready"
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatalf("upsert %s: %v", rt.Host, err)
+ }
+ }
+ if err := reg.UpsertPath(registry.RoutePath{
+ Host: front.Host, Prefix: prefix, Target: target.Host,
+ Account: front.Account, App: front.App,
+ }); err != nil {
+ t.Fatalf("upsert path %s/%s: %v", front.Host, prefix, err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+ return e
+}
+
+// TestPurgeHostOverrideAllowedFromAPathRoutedHost is the hole this closes.
+//
+// A path-routed request is cached under the hostname the browser asked for
+// (ti.to), but it is served by the target's route (go.tito.example), and
+// purgeModifier is built from that route. So the app's own Carlos-Purge lands
+// under go.tito.example and hits nothing, and the documented escape hatch —
+// naming ti.to explicitly — used to be refused too, because the override
+// required the same app and path routing crosses apps by design.
+//
+// The request really is path-routed here rather than simulated: it arrives on
+// ti.to/paulca and reaches the target through the prefix map, so this pins the
+// composition and not just resolvePurgeHost in isolation.
+func TestPurgeHostOverrideAllowedFromAPathRoutedHost(t *testing.T) {
+ e := purgePathEdge(t,
+ route("ti.to", "classic", "tenanta"),
+ route("go.tito.example", "gonew", "tenanta"),
+ "paulca",
+ func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge-Host", "ti.to")
+ w.Header().Set("Carlos-Purge", "/paulca/events/42")
+ w.WriteHeader(http.StatusOK)
+ })
+ seed(t, e.cache, "ti.to", "/paulca/events/42", "", "stale")
+ seed(t, e.cache, "ti.to", "/paulca/events/43", "", "untouched")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://ti.to/paulca/admin", nil))
+
+ if cached(e.cache, "ti.to", "/paulca/events/42", "") {
+ t.Fatal("a path-routed app could not purge its own stale page under the hostname it is served on")
+ }
+ if !cached(e.cache, "ti.to", "/paulca/events/43", "") {
+ t.Fatal("/paulca/events/43 was purged but nothing named it")
+ }
+ if got := rec.Header().Get("Carlos-Purge-Host"); got != "" {
+ t.Fatalf("Carlos-Purge-Host reached the client: %q", got)
+ }
+}
+
+// TestPurgeHostOverrideFromPathRoutedHostStillNeedsTheAccount — the new branch
+// drops the same-APP requirement and nothing else. Account remains the
+// boundary, and the edge checks it here rather than trusting that the
+// reconcile pass already refused a cross-account path row: a row written by an
+// older binary, or by hand, must not be able to reach another tenant's cache.
+//
+// Both accounts must be valid sqids (^[a-z]{3,16}$) or release.RouteAccount
+// folds them both onto the built-in ops account and the test asserts a
+// boundary it never crossed.
+func TestPurgeHostOverrideFromPathRoutedHostStillNeedsTheAccount(t *testing.T) {
+ e := purgePathEdge(t,
+ route("ti.to", "classic", "tenanta"),
+ route("go.tito.example", "gonew", "tenantb"),
+ "paulca",
+ func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge-Host", "ti.to")
+ w.Header().Set("Carlos-Purge", "/paulca/events/42")
+ w.WriteHeader(http.StatusOK)
+ })
+ seed(t, e.cache, "ti.to", "/paulca/events/42", "", "stale")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://ti.to/paulca/admin", nil))
+
+ if !cached(e.cache, "ti.to", "/paulca/events/42", "") {
+ t.Fatal("a path row pointing across ACCOUNTS let the target purge the front host's cache")
+ }
+}
+
+// TestPurgeHostOverrideRefusedWhenNoPrefixPointsAtTheRoute — the exception is
+// scoped to the route the prefix map actually names. A second route in the
+// same account, serving nothing of ti.to's traffic, gets the ordinary
+// cross-app refusal.
+//
+// Without this, "the front host path-routes somewhere" would be enough, and
+// any app in the account could purge ti.to by naming it.
+func TestPurgeHostOverrideRefusedWhenNoPrefixPointsAtTheRoute(t *testing.T) {
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge-Host", "ti.to")
+ w.Header().Set("Carlos-Purge", "/paulca/events/42")
+ w.WriteHeader(http.StatusOK)
+ }))
+ t.Cleanup(backend.Close)
+
+ reg := openReg(t)
+ for _, rt := range []registry.Route{
+ route("ti.to", "classic", "tenanta"),
+ route("go.tito.example", "gonew", "tenanta"),
+ route("bystander.example", "other", "tenanta"),
+ } {
+ rt.Kind, rt.Addr, rt.Status = "instance", backend.Listener.Addr().String(), "ready"
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatalf("upsert %s: %v", rt.Host, err)
+ }
+ }
+ // ti.to routes /paulca to go.tito.example — and to nothing else.
+ if err := reg.UpsertPath(registry.RoutePath{
+ Host: "ti.to", Prefix: "paulca", Target: "go.tito.example",
+ Account: "tenanta", App: "classic",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+ seed(t, e.cache, "ti.to", "/paulca/events/42", "", "stale")
+
+ // The write arrives on the bystander's OWN host, so it is served by the
+ // bystander's route — which no prefix of ti.to names.
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://bystander.example/write", nil))
+
+ if !cached(e.cache, "ti.to", "/paulca/events/42", "") {
+ t.Fatal("a route no prefix points at purged ti.to — the exception is not scoped to the path target")
+ }
+}