carlos / platform Public

docs: correct the path-routing spec where it disagreed with the code

Four corrections, each one a place a reader would have been misled.

§6 said the cross-box machinery "never has to learn about paths". True of the
HOSTNAME — one home box, one cert — but it reads as a guarantee that path
routing works across boxes, and it does not. The co-location constraint is
now stated plainly: a prefix's target route must be homed on the same box as
the hostname's own route.

§6 also now records ATTRIBUTION. A path hit returns the target's upstream
entry wholesale, so usage metering, the version header, purge tenancy and
activator keying all become the target's. Three are correct by design; purge
is a hole.

§7's wire shape was wrong. "Body is the whole map" — the door requires
{"paths": {...}} and answers 400 to a bare map. A consumer written from the
spec failed on its first call. The wrapper is the right design (a pointer
decode is what makes a misspelled field a 400 rather than a silent wholesale
clear), the spec just never caught up. §7 also now records the role decision
(Publish AND Instance, with the reasoning that overturned Publish-only) and
refuseServiceControlPlane, a real behavioural gate on this door that appeared
in no design document.

§11 gains cross-box path targets as a live check owed, and the purge gap as a
known gap with its bound: stale for at most the cache max TTL, and only for
responses that opt in with a positive max-age.

The implementation plan gets a header saying it is a historical artifact and
its code blocks are not what shipped — it carries at least three defects
implementers caught.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 7f4e6a3183f647a1dee1e4679b29b60f4d3c1b02 parent 2c773b4
2 files changed, +116 −9
  • docs/superpowers/plans/2026-09-03-path-based-routing.md +9 −0
  • docs/superpowers/specs/2026-09-02-path-based-routing-design.md +107 −9
diff --git a/docs/superpowers/plans/2026-09-03-path-based-routing.md b/docs/superpowers/plans/2026-09-03-path-based-routing.md
index 57f37a922..0c034aeed 100644
--- a/docs/superpowers/plans/2026-09-03-path-based-routing.md
+++ b/docs/superpowers/plans/2026-09-03-path-based-routing.md
@@ -1,5 +1,14 @@
# Path-Based Routing Implementation Plan
+> **HISTORICAL ARTIFACT — superseded by the code that shipped.** This plan was
+> written before implementation and is kept for the reasoning, not as a
+> description of the system. Its code blocks are NOT what shipped: implementers
+> found at least three defects in them (a missing serial bump on the write
+> door, a validation ordering in `SetClaimPaths`, and a wire shape the door
+> tightened to `{"paths": {...}}`). Read the design spec
+> (`docs/superpowers/specs/2026-09-02-path-based-routing-design.md`) for intent
+> and the source for behaviour; do not copy anything from here.
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let one hostname route to different upstreams by leading URL path segment, so Tito can migrate accounts from classic to Tito Go one at a time and reverse any of them.
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 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.