| index dbb503706..c6973294e 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 |
| @@ -291,11 +291,46 @@ twice, once on each box. |
| Untouched: `homemap`, `backhaul`, `certsync`, `certwarm`, `wildcard`, the |
| activator, the cache. That is the payoff of keeping the hostname as the unit |
| of placement: `ti.to` is one hostname with one home box and one cert whatever |
| -its prefix map says, so the cross-box machinery never has to learn about |
| -paths. `homemap`'s lookup is documented as mirroring `lookupUpstream` *"rule |
| -for rule"* — this spec keeps that true, because the rule it mirrors is the |
| +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-Version` header** 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 |
| @@ -318,10 +353,46 @@ Tito's door is a sibling: |
| PUT /api/cli/apps/{account}/{app}/domains/{host}/paths |
| ``` |
| |
| -Body is the whole map. It reuses the existing auth, account scoping and |
| -service roles wholesale, and its handler is the shape of the capability |
| -handler at apidomains.go:482: read claim under etag, replace one field, CAS |
| -write. |
| +**Wire shape.** The body is the whole map, WRAPPED in a `paths` field — not a |
| +bare map: |
| + |
| +```json |
| +{ "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 |
| @@ -402,9 +473,9 @@ Stages 1-4 are independently landable PRs. Nothing is observable until 5. |
| |
| --- |
| |
| -## 11. Live checks owed |
| +## 11. Live checks owed, and known gaps |
| |
| -Neither is a blocker for stages 1-5; both are blockers before Tito relies on |
| +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.page` be created through a supported |
| @@ -436,3 +507,30 @@ any of it. |
| 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.to` on one box and `go.tito.io` on 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). |
| + |
| +**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. |