# 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.

**Architecture:** A prefix *selects an existing route* rather than defining a backend — the same pointer shape `route_aliases` already uses. Tito publishes the whole prefix map for a hostname as an additive `paths` field on that hostname's existing claim object (`domains/<host>.json`); `ReconcileAliases` folds it into a new pass-owned `route_paths` table; `Fingerprint()` gains a section for that table so the edge's 2s watcher notices; the edge resolves the host exactly as today and then, only for hosts that have a prefix map, does one more lookup.

**Tech Stack:** Go, SQLite (modernc `sqlite` driver), `net/http`, `net/http/httptest`. No new dependencies.

**Spec:** `docs/superpowers/specs/2026-09-02-path-based-routing-design.md`

## Global Constraints

- **Additive only.** Every existing deployment has zero `route_paths` rows and must behave byte-for-byte as it does today. No migration step, no behaviour change until a row exists.
- **Prefixes are bare segments** — `paulca`, never `/paulca` — on the wire, in the table, and in the lookup. Surfaces may render a slash; nothing else adds one.
- **No prefix stripping.** The upstream receives the original path unchanged.
- **`maxPathsPerClaim = 10000`.** Deterministic drops in sorted key order, logged, never silent.
- **Tito is the only writer.** All CARLOS-side surfaces are read-only.
- **CARLOS never learns Tito's account model.** No reserved-prefix list, no account validation, no URL-shape assumptions beyond "first segment".
- **AGENTS.md rules apply:** `go build ./...`, `go vet ./...`, `go test ./... -count=1`, `gofmt -l .` all clean before every commit. Every change lands through a PR — never a direct commit or local merge to `main`.
- **Failure direction:** when anything is missing or refused, traffic falls through to the host's own route (classic). A broken flip must never 404; it must un-flip.

---

### Task 1: `route_paths` table, accessors, and the fingerprint section

**Files:**
- Modify: `internal/registry/registry.go` (schema in `migrate()` ~line 395; new `RoutePath` type and accessors after `DeleteAlias` ~line 1533; `Fingerprint()` ~line 1676)
- Test: `internal/registry/paths_test.go` (create)

**Interfaces:**
- Consumes: nothing (first task).
- Produces: `registry.RoutePath{Host, Prefix, Target, Account, App string}`; `(*Registry).ListPaths() ([]RoutePath, error)`; `(*Registry).UpsertPath(RoutePath) error`; `(*Registry).DeletePath(host, prefix string) error`. Task 3 writes through these, Task 4 reads through `ListPaths`.

- [ ] **Step 1: Write the failing tests**

Create `internal/registry/paths_test.go`:

```go
package registry

import "testing"

// TestPathRoundTrip: a written prefix row comes back, and a repoint of the
// same (host, prefix) replaces rather than duplicates.
func TestPathRoundTrip(t *testing.T) {
	reg := openTestRegistry(t)
	if err := reg.UpsertPath(RoutePath{
		Host: "ti.to", Prefix: "paulca", Target: "go.tito.io",
		Account: "tito", App: "classic",
	}); err != nil {
		t.Fatal(err)
	}
	got, err := reg.ListPaths()
	if err != nil {
		t.Fatal(err)
	}
	if len(got) != 1 {
		t.Fatalf("ListPaths returned %d rows, want 1", len(got))
	}
	if got[0].Target != "go.tito.io" {
		t.Fatalf("target = %q, want go.tito.io", got[0].Target)
	}

	if err := reg.UpsertPath(RoutePath{
		Host: "ti.to", Prefix: "paulca", Target: "beta.tito.io",
		Account: "tito", App: "classic",
	}); err != nil {
		t.Fatal(err)
	}
	got, err = reg.ListPaths()
	if err != nil {
		t.Fatal(err)
	}
	if len(got) != 1 {
		t.Fatalf("repoint produced %d rows, want 1 — (host, prefix) is the key", len(got))
	}
	if got[0].Target != "beta.tito.io" {
		t.Fatalf("target = %q, want beta.tito.io after repoint", got[0].Target)
	}
}

// TestDeletePathIsIdempotent: deleting a row that is already gone is a
// success, the same contract DeleteAlias carries. The reconcile pass deletes
// unconditionally, so this must not error.
func TestDeletePathIsIdempotent(t *testing.T) {
	reg := openTestRegistry(t)
	if err := reg.DeletePath("ti.to", "nobody"); err != nil {
		t.Fatalf("deleting an absent path row: %v", err)
	}
}

// TestPathsAreScopedToTheirHost: two hosts may carry the same prefix without
// colliding — the key is the pair, not the prefix.
func TestPathsAreScopedToTheirHost(t *testing.T) {
	reg := openTestRegistry(t)
	for _, h := range []string{"ti.to", "other.example"} {
		if err := reg.UpsertPath(RoutePath{
			Host: h, Prefix: "paulca", Target: "go.tito.io",
			Account: "tito", App: "classic",
		}); err != nil {
			t.Fatal(err)
		}
	}
	got, err := reg.ListPaths()
	if err != nil {
		t.Fatal(err)
	}
	if len(got) != 2 {
		t.Fatalf("got %d rows, want 2 — (host, prefix) is the key, not prefix alone", len(got))
	}
}

// TestFingerprintChangesOnPathMutation is the load-bearing test of this
// task. Fingerprint() is the 2s content hash the edge polls; a route_paths
// change it cannot see is a flip that reconciles perfectly and never reaches
// the live map — a silent false negative, the failure class Fingerprint's own
// header names as the one to engineer against.
func TestFingerprintChangesOnPathMutation(t *testing.T) {
	reg := openTestRegistry(t)
	if err := reg.UpsertPath(RoutePath{
		Host: "ti.to", Prefix: "paulca", Target: "go.tito.io",
		Account: "tito", App: "classic",
	}); err != nil {
		t.Fatal(err)
	}
	before, err := reg.Fingerprint()
	if err != nil {
		t.Fatal(err)
	}
	if err := reg.UpsertPath(RoutePath{
		Host: "ti.to", Prefix: "paulca", Target: "beta.tito.io",
		Account: "tito", App: "classic",
	}); err != nil {
		t.Fatal(err)
	}
	after, err := reg.Fingerprint()
	if err != nil {
		t.Fatal(err)
	}
	if before == after {
		t.Fatalf("fingerprint unchanged across a route_paths repoint: %q", after)
	}
}

// TestFingerprintChangesOnPathRemoval: the un-flip direction. Rollback is a
// row disappearing, and it must reach the edge on the same 2s bound as the
// flip did.
func TestFingerprintChangesOnPathRemoval(t *testing.T) {
	reg := openTestRegistry(t)
	if err := reg.UpsertPath(RoutePath{
		Host: "ti.to", Prefix: "paulca", Target: "go.tito.io",
		Account: "tito", App: "classic",
	}); err != nil {
		t.Fatal(err)
	}
	before, err := reg.Fingerprint()
	if err != nil {
		t.Fatal(err)
	}
	if err := reg.DeletePath("ti.to", "paulca"); err != nil {
		t.Fatal(err)
	}
	after, err := reg.Fingerprint()
	if err != nil {
		t.Fatal(err)
	}
	if before == after {
		t.Fatalf("fingerprint unchanged across a route_paths removal: %q", after)
	}
}
```

- [ ] **Step 2: Run the tests to verify they fail**

Run: `go test ./internal/registry/ -run 'TestPath|TestDeletePath|TestFingerprintChangesOnPath' -count=1`
Expected: FAIL — `undefined: RoutePath`, `reg.UpsertPath undefined`.

- [ ] **Step 3: Add the table to `migrate()`**

In `internal/registry/registry.go`, immediately after the `wildcard_domains` `CREATE TABLE IF NOT EXISTS` block:

```go
	// route_paths is a derived table (the domains reconcile pass owns every
	// row, materializing a claim's Paths map into it) — a plain CREATE TABLE
	// IF NOT EXISTS, the same additive shape as route_aliases and
	// wildcard_domains above, so existing DBs migrate on open with zero rows
	// and no behaviour change.
	//
	// The key is the PAIR: `routes` has host as its primary key and must
	// stay one row per hostname (that row owns the cert, the home box, the
	// DNS state and the aliases), so a hostname's prefixes live here instead.
	// prefix is a BARE segment — "paulca", never "/paulca".
	if _, err := r.sql.Exec(`
CREATE TABLE IF NOT EXISTS route_paths (
  host    TEXT NOT NULL,
  prefix  TEXT NOT NULL,
  target  TEXT NOT NULL,
  account TEXT NOT NULL DEFAULT '',
  app     TEXT NOT NULL DEFAULT '',
  PRIMARY KEY (host, prefix)
);`); err != nil {
		return err
	}
```

- [ ] **Step 4: Add the type and accessors**

In `internal/registry/registry.go`, after `DeleteAlias` (~line 1533):

```go
// RoutePath is one derived row of the route_paths table: a leading URL path
// segment on Host that is served by the route at Target instead of by Host's
// own route. Wholly pass-owned, like route_aliases and wildcard_domains:
// every row traces to a claim's Paths entry, so removal needs no ownership
// flag.
//
// A path row deliberately carries NO upstream, channel or environment of its
// own. It is a pointer; everything about how the traffic is served comes from
// Target's ordinary route row. That is what makes pointing a subset of
// prefixes at a second route a working canary rather than a special case.
//
// Prefix is a bare segment — "paulca", never "/paulca".
type RoutePath struct {
	Host    string
	Prefix  string
	Target  string
	Account string
	App     string
}

// ListPaths returns every path route, ordered by host then prefix.
func (r *Registry) ListPaths() ([]RoutePath, error) {
	rows, err := r.sql.Query(`SELECT host, prefix, target, account, app
FROM route_paths ORDER BY host, prefix`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []RoutePath
	for rows.Next() {
		var p RoutePath
		if err := rows.Scan(&p.Host, &p.Prefix, &p.Target, &p.Account, &p.App); err != nil {
			return nil, err
		}
		out = append(out, p)
	}
	return out, rows.Err()
}

// UpsertPath inserts or repoints one path route by its (host, prefix) key.
// Unlike UpsertAlias there are no stamp columns to preserve — nothing on this
// table is maintained on a separate cadence — so every column is overwritten.
func (r *Registry) UpsertPath(p RoutePath) error {
	_, err := r.sql.Exec(`
INSERT INTO route_paths (host, prefix, target, account, app)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(host, prefix) DO UPDATE SET
  target=excluded.target, account=excluded.account, app=excluded.app`,
		p.Host, p.Prefix, p.Target, p.Account, p.App)
	return err
}

// DeletePath removes one path route. Idempotent: a row already gone is a
// success, the same contract as DeleteAlias — the reconcile pass deletes
// unconditionally from its own view of what is no longer desired.
func (r *Registry) DeletePath(host, prefix string) error {
	_, err := r.sql.Exec(`DELETE FROM route_paths WHERE host = ? AND prefix = ?`, host, prefix)
	return err
}
```

- [ ] **Step 5: Add the fingerprint section**

In `Fingerprint()`, after the `wildcard_domains` section and before the final digest is returned:

```go
	// route_paths: which prefix on which hostname reaches which route is a
	// routing decision in exactly the sense route_aliases' alias/host is, so
	// it hashes here or a flip never reaches the live map. All five columns
	// hash — unlike route_aliases there is no box-stamped, cadence-driven
	// column on this table to exclude, so there is nothing here that could
	// force a pointless fleet-wide reload.
	fmt.Fprint(h, "\x1droute_paths\x1e")
	prows, err := db.Query(`SELECT host, prefix, target, account, app
FROM route_paths ORDER BY host, prefix`)
	if err != nil {
		return "", err
	}
	defer prows.Close()
	for prows.Next() {
		var host, prefix, target, account, app string
		if err := prows.Scan(&host, &prefix, &target, &account, &app); err != nil {
			return "", err
		}
		fmt.Fprintf(h, "%s\x1f%s\x1f%s\x1f%s\x1f%s\x1e", host, prefix, target, account, app)
	}
	if err := prows.Err(); err != nil {
		return "", err
	}
```

Also extend the `Fingerprint` doc comment, after the `wildcard_domains` paragraph:

```go
// route_paths is hashed for the same reload-latency reason, and it is the
// section most worth stating explicitly: a path flip is the ONLY routing
// change in the system whose whole observable effect is in this table. A
// missing section here would not degrade a flip, it would make flips do
// nothing at all — reconciled correctly, stored correctly, never served —
// until some unrelated route write happened to move the digest.
```

- [ ] **Step 6: Run the tests to verify they pass**

Run: `go test ./internal/registry/ -count=1`
Expected: PASS, including the four new tests and every existing registry test.

- [ ] **Step 7: Verify the whole build is clean**

Run: `go build ./... && go vet ./... && gofmt -l . && go test ./... -count=1`
Expected: no output from `gofmt -l .`, all tests pass. Zero `route_paths` rows exist anywhere, so nothing else can have changed behaviour.

- [ ] **Step 8: Commit**

```bash
git add internal/registry/registry.go internal/registry/paths_test.go
git commit -m "registry: route_paths table, accessors, and fingerprint section

A hostname's routes table row is its identity — cert, home box, DNS state,
aliases — so per-prefix routing lives in its own pass-owned table keyed on
(host, prefix), mirroring route_aliases.

The fingerprint section is the load-bearing half: a path flip's entire
observable effect is in this table, so without it flips would reconcile
correctly and never be served.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```

---

### Task 2: `Claim.Paths` and `SetClaimPaths`

**Files:**
- Modify: `internal/domains/claims.go` (`Claim` struct ~line 34; new `SetClaimPaths` after `SetClaimCapabilities` ~line 317)
- Test: `internal/domains/paths_test.go` (create)

**Interfaces:**
- Consumes: nothing from Task 1 (independent; may be built in parallel).
- Produces: `Claim.Paths map[string]string`; `domains.SetClaimPaths(ctx context.Context, s release.Store, host, account, app string, paths map[string]string) (changed bool, err error)`; `domains.ErrTooManyPaths`; `domains.MaxPathsPerClaim`. Task 3 reads `Claim.Paths`; Task 5 calls `SetClaimPaths`.

- [ ] **Step 1: Write the failing tests**

Create `internal/domains/paths_test.go`. Use whatever in-memory `release.Store` fake the existing `internal/domains` tests use — check `internal/domains/claims_test.go` for its constructor and reuse it verbatim rather than writing a second one.

```go
package domains

import (
	"context"
	"errors"
	"strconv"
	"testing"
)

// TestSetClaimPathsRoundTrip: the map written is the map read back.
func TestSetClaimPathsRoundTrip(t *testing.T) {
	ctx := context.Background()
	s := newTestStore(t) // see claims_test.go for the existing fake
	if _, _, err := AttachClaim(ctx, s, Claim{
		Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
		Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z",
	}); err != nil {
		t.Fatal(err)
	}
	changed, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic",
		map[string]string{"paulca": "go.tito.io"})
	if err != nil {
		t.Fatal(err)
	}
	if !changed {
		t.Fatal("changed = false on the first write of a paths map")
	}
	c, err := GetClaim(ctx, s, "ti.to")
	if err != nil {
		t.Fatal(err)
	}
	if c.Paths["paulca"] != "go.tito.io" {
		t.Fatalf("paths = %v, want paulca -> go.tito.io", c.Paths)
	}
}

// TestSetClaimPathsIsWholesale: publishing is a REPLACE, not a merge. Tito
// regenerates the whole map from its own database, so an account it no longer
// lists must disappear — that is how a rollback reaches the edge.
func TestSetClaimPathsIsWholesale(t *testing.T) {
	ctx := context.Background()
	s := newTestStore(t)
	if _, _, err := AttachClaim(ctx, s, Claim{
		Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
		Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z",
	}); err != nil {
		t.Fatal(err)
	}
	if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic",
		map[string]string{"paulca": "go.tito.io", "bigcorp": "go.tito.io"}); err != nil {
		t.Fatal(err)
	}
	if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic",
		map[string]string{"paulca": "go.tito.io"}); err != nil {
		t.Fatal(err)
	}
	c, err := GetClaim(ctx, s, "ti.to")
	if err != nil {
		t.Fatal(err)
	}
	if _, still := c.Paths["bigcorp"]; still {
		t.Fatal("bigcorp survived a republish that omitted it — publishing must replace, not merge")
	}
}

// TestSetClaimPathsEmptyClearsTheMap: the final cutover. When every account
// is across, Tito repoints the host's own route and publishes an empty map.
func TestSetClaimPathsEmptyClearsTheMap(t *testing.T) {
	ctx := context.Background()
	s := newTestStore(t)
	if _, _, err := AttachClaim(ctx, s, Claim{
		Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
		Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z",
	}); err != nil {
		t.Fatal(err)
	}
	if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic",
		map[string]string{"paulca": "go.tito.io"}); err != nil {
		t.Fatal(err)
	}
	if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", nil); err != nil {
		t.Fatal(err)
	}
	c, err := GetClaim(ctx, s, "ti.to")
	if err != nil {
		t.Fatal(err)
	}
	if len(c.Paths) != 0 {
		t.Fatalf("paths = %v, want empty after publishing an empty map", c.Paths)
	}
}

// TestSetClaimPathsIsOwnerGated: a caller who does not hold the claim gets
// the same neutral ErrHostUnavailable the attach door uses — no existence
// oracle, byte-identical to the answer for a claim that does not exist.
func TestSetClaimPathsIsOwnerGated(t *testing.T) {
	ctx := context.Background()
	s := newTestStore(t)
	if _, _, err := AttachClaim(ctx, s, Claim{
		Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
		Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z",
	}); err != nil {
		t.Fatal(err)
	}
	if _, err := SetClaimPaths(ctx, s, "ti.to", "someone-else", "classic",
		map[string]string{"paulca": "evil.example"}); !errors.Is(err, ErrHostUnavailable) {
		t.Fatalf("err = %v, want ErrHostUnavailable for a non-owner", err)
	}
	if _, err := SetClaimPaths(ctx, s, "never-claimed.example", "tito", "classic",
		map[string]string{"a": "b"}); !errors.Is(err, ErrHostUnavailable) {
		t.Fatalf("err = %v, want the identical ErrHostUnavailable for an absent claim", err)
	}
}

// TestSetClaimPathsUnchangedIsNoWrite: republishing an identical map must not
// burn a bucket write. Tito republishes wholesale as a repair, so the no-op
// case is the common one.
func TestSetClaimPathsUnchangedIsNoWrite(t *testing.T) {
	ctx := context.Background()
	s := newTestStore(t)
	if _, _, err := AttachClaim(ctx, s, Claim{
		Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
		Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z",
	}); err != nil {
		t.Fatal(err)
	}
	m := map[string]string{"paulca": "go.tito.io"}
	if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", m); err != nil {
		t.Fatal(err)
	}
	changed, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", m)
	if err != nil {
		t.Fatal(err)
	}
	if changed {
		t.Fatal("changed = true for an identical republish — a no-op must not write")
	}
}

// TestSetClaimPathsRefusesOversizeMap: the cap is enforced at the door, so an
// oversize map is refused whole rather than silently truncated into the
// bucket. (The reconcile pass caps again, defensively, for hand-written
// claims — see Task 3.)
func TestSetClaimPathsRefusesOversizeMap(t *testing.T) {
	ctx := context.Background()
	s := newTestStore(t)
	if _, _, err := AttachClaim(ctx, s, Claim{
		Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
		Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z",
	}); err != nil {
		t.Fatal(err)
	}
	big := make(map[string]string, MaxPathsPerClaim+1)
	for i := 0; i <= MaxPathsPerClaim; i++ {
		big["acct"+strconv.Itoa(i)] = "go.tito.io"
	}
	if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", big); !errors.Is(err, ErrTooManyPaths) {
		t.Fatalf("err = %v, want ErrTooManyPaths", err)
	}
}

// TestSetClaimPathsRejectsMalformedPrefixes: a prefix is a bare segment. A
// leading slash, an embedded slash, or an empty key would each produce a row
// the edge could never match, so they are refused at the door rather than
// stored as dead weight.
func TestSetClaimPathsRejectsMalformedPrefixes(t *testing.T) {
	ctx := context.Background()
	s := newTestStore(t)
	if _, _, err := AttachClaim(ctx, s, Claim{
		Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
		Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z",
	}); err != nil {
		t.Fatal(err)
	}
	for _, bad := range []string{"/paulca", "paulca/awesomeconf", "", "with space"} {
		if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic",
			map[string]string{bad: "go.tito.io"}); !errors.Is(err, ErrBadPathPrefix) {
			t.Fatalf("prefix %q: err = %v, want ErrBadPathPrefix", bad, err)
		}
	}
}
```

- [ ] **Step 2: Run the tests to verify they fail**

Run: `go test ./internal/domains/ -run TestSetClaimPaths -count=1`
Expected: FAIL — `undefined: SetClaimPaths`, `undefined: MaxPathsPerClaim`.

- [ ] **Step 3: Add the `Paths` field to `Claim`**

In `internal/domains/claims.go`, inside the `Claim` struct, after `Catchall`:

```go
	// Paths splits this hostname's traffic by leading URL segment: a bare
	// first segment ("paulca", never "/paulca") mapped to the HOSTNAME of the
	// route that serves it. Absent or empty means this host routes by
	// hostname alone — the behaviour of every host today, and the reason this
	// field is additive in the strongest sense: no row, no map, no change.
	//
	// This is a THIRD claim capability alongside Wildcard/Catchall, and it is
	// declared here for the reason stated above them — converge preserves only
	// DECLARED fields, so an undeclared field would be stripped by any binary
	// that rewrote the claim.
	//
	// The writer is the tenant's own platform, publishing the whole map
	// regenerated from its own database. Deltas were rejected deliberately: a
	// lost delta desyncs the two systems silently, whereas a wholesale
	// republish is always a full repair.
	Paths map[string]string `json:"paths,omitempty"`
```

- [ ] **Step 4: Add the errors, the cap, and `SetClaimPaths`**

In `internal/domains/claims.go`, after `SetClaimCapabilities`:

```go
// MaxPathsPerClaim bounds a claim's prefix map. Unlike maxWildcardDomains
// this defends no external budget — there is no ACME equivalent for a path
// row — it bounds the claim object's size and the per-box fold that reads it
// every fast-converge tick. At roughly 40 bytes an entry this is a ~400KB
// object refolded every couple of seconds, which is comfortable; sharding a
// host's map across objects is deliberately not built until a real host
// approaches this number.
const MaxPathsPerClaim = 10000

// ErrTooManyPaths is returned by SetClaimPaths for a map above
// MaxPathsPerClaim. Refused whole: a silently truncated map would route a
// subset of accounts and look successful.
var ErrTooManyPaths = errors.New("too many path prefixes")

// ErrBadPathPrefix is returned by SetClaimPaths for a key that is not a bare
// single path segment.
var ErrBadPathPrefix = errors.New("path prefix must be a single bare segment")

// validPathPrefix reports whether p is a bare first path segment: non-empty,
// no slashes, and nothing that would not survive a URL path intact. The edge
// matches the request's first segment by EQUALITY, so anything that cannot be
// a first segment could never match and must not be stored.
func validPathPrefix(p string) bool {
	if p == "" || len(p) > 255 {
		return false
	}
	for i := 0; i < len(p); i++ {
		c := p[i]
		switch {
		case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
		case c == '-' || c == '_' || c == '.' || c == '~':
		default:
			return false
		}
	}
	// "." and ".." would be normalised away by any path cleaner between the
	// browser and here, so a row keyed on either is unreachable by
	// construction.
	return p != "." && p != ".."
}

// SetClaimPaths is the ONLY writer of a claim's Paths field, the same
// single-writer discipline SetClaimCapabilities holds over Wildcard/Catchall
// and for the same reason: AttachClaim's converge repoints Route and nothing
// else, so it can preserve this map but must never invent or strip it.
//
// The write is WHOLESALE — paths replaces the stored map rather than merging
// into it. That is the contract the caller's platform relies on: it
// regenerates the map from its own database and publishes the result, so an
// entry it no longer lists must disappear. Merging would make rollback
// impossible to express.
//
// Owner-gated exactly as SetClaimCapabilities is: a caller who does not hold
// the claim gets the neutral ErrHostUnavailable, and a missing claim answers
// identically, so this door is not an existence oracle.
func SetClaimPaths(ctx context.Context, s release.Store, host, account, app string, paths map[string]string) (changed bool, err error) {
	if len(paths) > MaxPathsPerClaim {
		return false, ErrTooManyPaths
	}
	for prefix, target := range paths {
		if !validPathPrefix(prefix) {
			return false, ErrBadPathPrefix
		}
		// The target must be a plausible hostname. Whether it is a route this
		// account owns is decided by the reconcile pass, which — unlike this
		// door — can see the registry.
		if n, ok := NormalizeAliasHost(target); !ok || n != target {
			return false, fmt.Errorf("domains: path prefix %q: target %q is not a normalised hostname", prefix, target)
		}
	}
	for attempt := 0; ; attempt++ {
		cur, etag, err := getClaimETag(ctx, s, host)
		if errors.Is(err, os.ErrNotExist) {
			return false, ErrHostUnavailable
		} else if err != nil {
			return false, err
		}
		if cur.Account != account || cur.App != app {
			return false, ErrHostUnavailable
		}
		if samePaths(cur.Paths, paths) {
			return false, nil
		}
		next := *cur
		if len(paths) == 0 {
			next.Paths = nil // omitempty: an empty map and no map are one state
		} else {
			next.Paths = make(map[string]string, len(paths))
			for k, v := range paths {
				next.Paths[k] = v
			}
		}
		body, err := json.Marshal(next)
		if err != nil {
			return false, err
		}
		if _, err := s.Put(ctx, ClaimKey(host), body, etag); err == nil {
			return true, nil
		} else if errors.Is(err, release.ErrCASConflict) && attempt < 3 {
			continue
		} else {
			return false, err
		}
	}
}

// samePaths reports whether two prefix maps are equal, treating nil and empty
// as the same state. Republishing an unchanged map is the COMMON case — the
// caller republishes wholesale as a repair — so this is what keeps a repair
// from costing a bucket write and a fleet-wide edge reload.
func samePaths(a, b map[string]string) bool {
	if len(a) != len(b) {
		return false
	}
	for k, v := range a {
		if b[k] != v {
			return false
		}
	}
	return true
}
```

Confirm `fmt` is imported in `claims.go` (it is — `getClaimETag` uses it).

- [ ] **Step 5: Run the tests to verify they pass**

Run: `go test ./internal/domains/ -count=1`
Expected: PASS.

- [ ] **Step 6: Verify converge round-trips the new field**

`AttachClaim`'s converge rewrites a claim; the `Paths` map must survive it. Confirm by reading `AttachClaim`'s converge branch (`internal/domains/claims.go:105`) — it constructs its next value from the current claim, so a declared field is preserved. If it instead builds a fresh `Claim` literal, add `Paths` to that literal and add this test:

```go
// TestAttachConvergePreservesPaths: a repoint must not strip the prefix map.
func TestAttachConvergePreservesPaths(t *testing.T) {
	ctx := context.Background()
	s := newTestStore(t)
	if _, _, err := AttachClaim(ctx, s, Claim{
		Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
		Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z",
	}); err != nil {
		t.Fatal(err)
	}
	if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic",
		map[string]string{"paulca": "go.tito.io"}); err != nil {
		t.Fatal(err)
	}
	if _, _, err := AttachClaim(ctx, s, Claim{
		Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
		Route: "ti-to-v2.tito.io", CreatedAt: "2026-09-03T00:00:00Z",
	}); err != nil {
		t.Fatal(err)
	}
	c, err := GetClaim(ctx, s, "ti.to")
	if err != nil {
		t.Fatal(err)
	}
	if c.Paths["paulca"] != "go.tito.io" {
		t.Fatalf("paths = %v, want the map preserved across a converge repoint", c.Paths)
	}
}
```

Run: `go test ./internal/domains/ -count=1` — PASS.

- [ ] **Step 7: Commit**

```bash
git add internal/domains/claims.go internal/domains/paths_test.go
git commit -m "domains: claims carry a prefix map, written only by SetClaimPaths

domains/<host>.json is already one CAS-written object per hostname carrying
account and app — exactly the granularity and write semantics a per-host
prefix map needs, so path routing is a third claim capability rather than a
new control-plane object type.

Writes are wholesale: the tenant's platform regenerates the map from its own
database and publishes the result, so a republish is always a full repair and
a lost update can never desync the two systems.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```

---

### Task 3: Derive `route_paths` in the reconcile pass

**Files:**
- Modify: `internal/domains/aliases.go` (`ReconcileAliases`, line 48; the desired-set loop and the pass-owned deletion tail ~line 190-222)
- Test: `internal/domains/aliases_paths_test.go` (create)

**Interfaces:**
- Consumes: `registry.RoutePath`, `(*Registry).ListPaths/UpsertPath/DeletePath` (Task 1); `Claim.Paths`, `MaxPathsPerClaim` (Task 2).
- Produces: no new exported symbols. `route_paths` rows now exist in the registry for Task 4 to read.

- [ ] **Step 1: Write the failing tests**

Create `internal/domains/aliases_paths_test.go`. Reuse the store fake and the registry helper the existing `internal/domains` alias tests use — read `internal/domains/aliases_test.go` and copy its setup verbatim.

```go
package domains

import (
	"context"
	"strconv"
	"testing"
	"time"

	"github.com/carlosframework/platform/internal/registry"
)

// pathsByPrefix collapses ListPaths into a prefix -> target map for one host.
func pathsByPrefix(t *testing.T, reg *registry.Registry, host string) map[string]string {
	t.Helper()
	rows, err := reg.ListPaths()
	if err != nil {
		t.Fatal(err)
	}
	out := map[string]string{}
	for _, p := range rows {
		if p.Host == host {
			out[p.Prefix] = p.Target
		}
	}
	return out
}

// TestReconcileDerivesPathRows is the feature: a claim's Paths map becomes
// route_paths rows.
func TestReconcileDerivesPathRows(t *testing.T) {
	ctx := context.Background()
	s := newTestStore(t)
	reg := newTestRegistry(t)
	mustRoute(t, reg, "ti.to", "tito", "classic")
	mustRoute(t, reg, "go.tito.io", "tito", "classic")
	mustClaimWithPaths(t, ctx, s, "ti.to", "tito", "classic", "ti.to",
		map[string]string{"paulca": "go.tito.io"})

	if err := ReconcileAliases(ctx, s, reg, time.Now, nil, nil); err != nil {
		t.Fatal(err)
	}
	if got := pathsByPrefix(t, reg, "ti.to"); got["paulca"] != "go.tito.io" {
		t.Fatalf("route_paths = %v, want paulca -> go.tito.io", got)
	}
}

// TestReconcileRemovesUndesiredPathRows is the rollback path and the
// pass-ownership property: a prefix dropped from the published map disappears
// from the table on the next pass, with no delete verb anywhere.
func TestReconcileRemovesUndesiredPathRows(t *testing.T) {
	ctx := context.Background()
	s := newTestStore(t)
	reg := newTestRegistry(t)
	mustRoute(t, reg, "ti.to", "tito", "classic")
	mustRoute(t, reg, "go.tito.io", "tito", "classic")
	mustClaimWithPaths(t, ctx, s, "ti.to", "tito", "classic", "ti.to",
		map[string]string{"paulca": "go.tito.io", "bigcorp": "go.tito.io"})
	if err := ReconcileAliases(ctx, s, reg, time.Now, nil, nil); err != nil {
		t.Fatal(err)
	}
	if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic",
		map[string]string{"paulca": "go.tito.io"}); err != nil {
		t.Fatal(err)
	}
	if err := ReconcileAliases(ctx, s, reg, time.Now, nil, nil); err != nil {
		t.Fatal(err)
	}
	got := pathsByPrefix(t, reg, "ti.to")
	if _, still := got["bigcorp"]; still {
		t.Fatalf("route_paths = %v, want bigcorp gone after it left the published map", got)
	}
	if got["paulca"] != "go.tito.io" {
		t.Fatalf("route_paths = %v, want paulca still routed", got)
	}
}

// TestReconcileRefusesCrossTenantTarget is the one genuinely new authorization
// rule in this feature: a prefix must never point another tenant's traffic at
// your backend.
func TestReconcileRefusesCrossTenantTarget(t *testing.T) {
	ctx := context.Background()
	s := newTestStore(t)
	reg := newTestRegistry(t)
	mustRoute(t, reg, "ti.to", "tito", "classic")
	mustRoute(t, reg, "victim.example", "someone-else", "their-app")
	mustClaimWithPaths(t, ctx, s, "ti.to", "tito", "classic", "ti.to",
		map[string]string{"paulca": "victim.example"})

	if err := ReconcileAliases(ctx, s, reg, time.Now, nil, nil); err != nil {
		t.Fatal(err)
	}
	if got := pathsByPrefix(t, reg, "ti.to"); len(got) != 0 {
		t.Fatalf("route_paths = %v, want no row — the target belongs to another account", got)
	}
}

// TestReconcileSkipsUnknownTarget: a target that is not a local route yields
// no row, so the prefix falls through to the host's own route. That is the
// correct failure direction for a migration — a missing Go route serves
// classic rather than 404ing.
func TestReconcileSkipsUnknownTarget(t *testing.T) {
	ctx := context.Background()
	s := newTestStore(t)
	reg := newTestRegistry(t)
	mustRoute(t, reg, "ti.to", "tito", "classic")
	mustClaimWithPaths(t, ctx, s, "ti.to", "tito", "classic", "ti.to",
		map[string]string{"paulca": "not-deployed-here.example"})

	if err := ReconcileAliases(ctx, s, reg, time.Now, nil, nil); err != nil {
		t.Fatal(err)
	}
	if got := pathsByPrefix(t, reg, "ti.to"); len(got) != 0 {
		t.Fatalf("route_paths = %v, want no row for a target that is not a local route", got)
	}
}

// TestReconcileSkipsWhenClaimHostIsNotALocalRoute: the prefix map belongs to
// the hostname's own route. If that route lives on another box, this box
// derives nothing — the same per-box rule the alias half already follows.
func TestReconcileSkipsWhenClaimHostIsNotALocalRoute(t *testing.T) {
	ctx := context.Background()
	s := newTestStore(t)
	reg := newTestRegistry(t)
	mustRoute(t, reg, "go.tito.io", "tito", "classic")
	mustClaimWithPaths(t, ctx, s, "ti.to", "tito", "classic", "ti.to",
		map[string]string{"paulca": "go.tito.io"})

	if err := ReconcileAliases(ctx, s, reg, time.Now, nil, nil); err != nil {
		t.Fatal(err)
	}
	if got := pathsByPrefix(t, reg, "ti.to"); len(got) != 0 {
		t.Fatalf("route_paths = %v, want nothing — ti.to is not a route on this box", got)
	}
}

// TestReconcileCapsPathRows: a hand-written claim above the cap is truncated
// deterministically (sorted key order), never silently and never at random.
func TestReconcileCapsPathRows(t *testing.T) {
	ctx := context.Background()
	s := newTestStore(t)
	reg := newTestRegistry(t)
	mustRoute(t, reg, "ti.to", "tito", "classic")
	mustRoute(t, reg, "go.tito.io", "tito", "classic")
	big := make(map[string]string, MaxPathsPerClaim+10)
	for i := 0; i < MaxPathsPerClaim+10; i++ {
		big["acct"+strconv.Itoa(i)] = "go.tito.io"
	}
	mustClaimWithPaths(t, ctx, s, "ti.to", "tito", "classic", "ti.to", big)

	if err := ReconcileAliases(ctx, s, reg, time.Now, nil, nil); err != nil {
		t.Fatal(err)
	}
	if got := pathsByPrefix(t, reg, "ti.to"); len(got) != MaxPathsPerClaim {
		t.Fatalf("derived %d rows, want the cap of %d", len(got), MaxPathsPerClaim)
	}
}
```

Add these helpers to the same file (adapt the route/claim shapes to whatever the existing alias tests use):

```go
func mustRoute(t *testing.T, reg *registry.Registry, host, account, app string) {
	t.Helper()
	if err := reg.Upsert(registry.Route{
		Host: host, Kind: "service", Addr: "127.0.0.1:1", Status: "ready",
		Account: account, App: app,
	}); err != nil {
		t.Fatalf("upsert route %s: %v", host, err)
	}
}

func mustClaimWithPaths(t *testing.T, ctx context.Context, s release.Store,
	host, account, app, route string, paths map[string]string) {
	t.Helper()
	if _, _, err := AttachClaim(ctx, s, Claim{
		Schema: 1, Host: host, Account: account, App: app, Route: route,
		CreatedAt: "2026-09-03T00:00:00Z",
	}); err != nil {
		t.Fatalf("attach claim %s: %v", host, err)
	}
	// Written directly rather than through SetClaimPaths so the cap test can
	// stage an oversize map the door would refuse.
	cur, etag, err := getClaimETag(ctx, s, host)
	if err != nil {
		t.Fatal(err)
	}
	cur.Paths = paths
	body, err := json.Marshal(cur)
	if err != nil {
		t.Fatal(err)
	}
	if _, err := s.Put(ctx, ClaimKey(host), body, etag); err != nil {
		t.Fatal(err)
	}
}
```

Import `encoding/json` and `github.com/carlosframework/platform/internal/release` in the test file.

- [ ] **Step 2: Run the tests to verify they fail**

Run: `go test ./internal/domains/ -run TestReconcile -count=1`
Expected: FAIL — the new tests find zero derived rows.

- [ ] **Step 3: Derive the desired path set**

In `internal/domains/aliases.go`, add `"sort"` to the imports. Alongside `desired` and `desiredWild` near the top of `ReconcileAliases`, add:

```go
	desiredPaths := map[string]registry.RoutePath{} // key: host + "\x00" + prefix
```

Then inside the `for _, c := range claims` loop, after the wildcard derivation, add:

```go
		// Path derivation (prefix routing). A claim's Paths map splits its own
		// hostname's traffic by leading URL segment; each entry names the
		// hostname of the route that serves that segment.
		//
		// Gated on the claim's host being a LOCAL route whose tenancy matches
		// the claim — the same per-box rule the alias half above follows, and
		// for the same reason: the prefix map belongs to that route, so a box
		// that does not serve the route derives nothing.
		if len(c.Paths) > 0 {
			hostRt, ok := byHost[c.Host]
			if !ok {
				// The hostname's own route is not on this box; another box
				// derives these rows.
			} else if hostRt.App != c.App || release.RouteAccount(hostRt) != c.Account {
				log.Printf("domains: paths on %s refused: route belongs to %s/%s, claim says %s/%s",
					c.Host, release.RouteAccount(hostRt), hostRt.App, c.Account, c.App)
			} else {
				// Sorted so the cap below truncates DETERMINISTICALLY: every
				// box must derive the identical subset, or the same URL would
				// resolve differently depending on which edge answered.
				prefixes := make([]string, 0, len(c.Paths))
				for p := range c.Paths {
					prefixes = append(prefixes, p)
				}
				sort.Strings(prefixes)
				if len(prefixes) > MaxPathsPerClaim {
					log.Printf("domains: paths on %s: %d prefixes exceeds the cap of %d, dropping %d in sorted order",
						c.Host, len(prefixes), MaxPathsPerClaim, len(prefixes)-MaxPathsPerClaim)
					prefixes = prefixes[:MaxPathsPerClaim]
				}
				for _, prefix := range prefixes {
					target := c.Paths[prefix]
					if !validPathPrefix(prefix) {
						log.Printf("domains: path %s/%s refused: not a bare path segment", c.Host, prefix)
						continue
					}
					tgtRt, ok := byHost[target]
					if !ok {
						// Target is not a route on this box. No row, so the
						// prefix falls through to the host's own route — the
						// right failure direction for a migration: a missing
						// destination serves the ORIGIN, never a 404.
						continue
					}
					// The authorization rule this feature adds: a prefix must
					// never point another tenant's traffic at your backend.
					// Account only — an account routing between its own apps
					// (classic and Go ARE two apps) is the whole use case.
					if release.RouteAccount(tgtRt) != c.Account {
						log.Printf("domains: path %s/%s refused: target %s belongs to %s, claim says %s",
							c.Host, prefix, target, release.RouteAccount(tgtRt), c.Account)
						continue
					}
					desiredPaths[c.Host+"\x00"+prefix] = registry.RoutePath{
						Host: c.Host, Prefix: prefix, Target: target,
						Account: c.Account, App: c.App,
					}
				}
			}
		}
```

- [ ] **Step 4: Write and prune the table**

At the end of `ReconcileAliases`, after the `wildcard_domains` deletion loop and before `return nil`:

```go
	// route_paths is wholly pass-owned, like route_aliases and
	// wildcard_domains: every row traces to a claim's Paths entry, so a
	// prefix removed from a republished map disappears here with no delete
	// verb anywhere. That is exactly how a per-account rollback reaches the
	// edge.
	existingPaths, err := reg.ListPaths()
	if err != nil {
		return err
	}
	for _, p := range desiredPaths {
		if err := reg.UpsertPath(p); err != nil {
			log.Printf("domains: upsert path %s/%s: %v", p.Host, p.Prefix, err)
		}
	}
	for _, p := range existingPaths {
		if _, ok := desiredPaths[p.Host+"\x00"+p.Prefix]; !ok {
			if err := reg.DeletePath(p.Host, p.Prefix); err != nil {
				log.Printf("domains: remove path %s/%s: %v", p.Host, p.Prefix, err)
			}
		}
	}
	return nil
```

`UpsertPath` is unconditional rather than diffed-first (the shape the wildcard half uses) because the row has five short columns and no stamps to preserve — a redundant write costs one statement and cannot lose state. `Fingerprint` hashes content, not write counts, so this does not cause spurious reloads.

- [ ] **Step 5: Run the tests to verify they pass**

Run: `go test ./internal/domains/ -count=1`
Expected: PASS, including every existing alias and wildcard test — the pass's existing behaviour must be untouched.

- [ ] **Step 6: Commit**

```bash
git add internal/domains/aliases.go internal/domains/aliases_paths_test.go
git commit -m "domains: derive route_paths from claims, gated on same-account targets

Extends the pass that already materializes route_aliases and
wildcard_domains, keeping the same wholly-pass-owned property: a prefix
dropped from a republished map disappears on the next tick, so rollback needs
no delete verb.

Adds one authorization rule — a prefix's target must belong to the claim's
account — so a prefix can never point another tenant's traffic at your
backend. A target that is not a local route yields no row, so the prefix falls
through to the hostname's own route: a missing destination serves the origin,
never a 404.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```

---

### Task 4: The edge resolves prefixes

**Files:**
- Modify: `internal/edge/edge.go` (`upstream` struct line 244; `Reload`'s alias fold ~line 591-606; `lookupUpstream` line 866; `ServeHTTP` dispatch line 930)
- Test: `internal/edge/path_test.go` (create)

**Interfaces:**
- Consumes: `registry.RoutePath`, `(*Registry).ListPaths` (Task 1); `route_paths` rows (Task 3).
- Produces: `upstream.paths map[string]string`; `(*Edge).lookupUpstreamPath(host, path string) (upstream, bool)`; `lookupIn(m map[string]upstream, host string) (upstream, bool)`. Nothing outside `internal/edge` consumes these.

- [ ] **Step 1: Write the failing tests**

Create `internal/edge/path_test.go`:

```go
package edge

import (
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/carlosframework/platform/internal/registry"
)

// upsertPath writes one route_paths row, the shape the domains reconcile pass
// derives from a claim's Paths map.
func upsertPath(t *testing.T, reg *registry.Registry, host, prefix, target string) {
	t.Helper()
	if err := reg.UpsertPath(registry.RoutePath{
		Host: host, Prefix: prefix, Target: target, Account: "acct", App: "app",
	}); err != nil {
		t.Fatalf("upsert path %s/%s -> %s: %v", host, prefix, target, err)
	}
}

// splitHost stands up one hostname whose default route is "classic" and whose
// "flipped" prefix reaches a second route, returning the live test server.
func splitHost(t *testing.T) *httptest.Server {
	t.Helper()
	classic, _ := hostEchoBackend(t, "classic")
	golive, _ := hostEchoBackend(t, "go")
	reg := openReg(t)
	if err := reg.Upsert(registry.Route{
		Host: "ti.to", Kind: "service",
		Addr: classic.Listener.Addr().String(), Status: "ready",
	}); err != nil {
		t.Fatal(err)
	}
	if err := reg.Upsert(registry.Route{
		Host: "go.tito.io", Kind: "service",
		Addr: golive.Listener.Addr().String(), Status: "ready",
	}); err != nil {
		t.Fatal(err)
	}
	upsertPath(t, reg, "ti.to", "flipped", "go.tito.io")

	e, err := New(reg, nil)
	if err != nil {
		t.Fatalf("New: %v", err)
	}
	srv := httptest.NewServer(e)
	t.Cleanup(srv.Close)
	return srv
}

// TestPathRouteReachesTarget is the feature: a flipped prefix reaches the
// other route's backend while the same hostname's other traffic does not.
func TestPathRouteReachesTarget(t *testing.T) {
	srv := splitHost(t)
	for _, tc := range []struct{ path, want string }{
		{"/flipped", "go"},
		{"/flipped/awesomeconf", "go"},
		{"/flipped/awesomeconf/admin", "go"},
		{"/notflipped", "classic"},
		{"/notflipped/event", "classic"},
		{"/", "classic"},
		{"", "classic"},
	} {
		code, body := getHostPath(t, srv.URL, "ti.to", tc.path)
		if code != http.StatusOK {
			t.Fatalf("%s: status = %d, want 200", tc.path, code)
		}
		if body != tc.want {
			t.Fatalf("%s: body = %q, want %q", tc.path, body, tc.want)
		}
	}
}

// TestPathMatchIsSegmentBounded is the boundary pin. A prefix match must be
// segment-wise equality, never a string prefix — "flippedxyz" is a different
// account from "flipped" and must not be dragged across the migration with it.
func TestPathMatchIsSegmentBounded(t *testing.T) {
	srv := splitHost(t)
	for _, p := range []string{"/flippedxyz", "/flipped-two", "/flippedxyz/event"} {
		code, body := getHostPath(t, srv.URL, "ti.to", p)
		if code != http.StatusOK {
			t.Fatalf("%s: status = %d, want 200", p, code)
		}
		if body != "classic" {
			t.Fatalf("%s: body = %q, want classic — a prefix matches whole segments only", p, body)
		}
	}
}

// TestPathRouteDoesNotStripThePrefix: the upstream sees the original URL. The
// flipped and unflipped URL spaces must be identical, which is the whole
// point of a seamless migration.
func TestPathRouteDoesNotStripThePrefix(t *testing.T) {
	classic, _ := hostEchoBackend(t, "classic")
	golive := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte(r.URL.Path))
	}))
	t.Cleanup(golive.Close)

	reg := openReg(t)
	if err := reg.Upsert(registry.Route{
		Host: "ti.to", Kind: "service",
		Addr: classic.Listener.Addr().String(), Status: "ready",
	}); err != nil {
		t.Fatal(err)
	}
	if err := reg.Upsert(registry.Route{
		Host: "go.tito.io", Kind: "service",
		Addr: golive.Listener.Addr().String(), Status: "ready",
	}); err != nil {
		t.Fatal(err)
	}
	upsertPath(t, reg, "ti.to", "paulca", "go.tito.io")

	e, err := New(reg, nil)
	if err != nil {
		t.Fatal(err)
	}
	srv := httptest.NewServer(e)
	t.Cleanup(srv.Close)

	_, body := getHostPath(t, srv.URL, "ti.to", "/paulca/awesomeconf")
	if body != "/paulca/awesomeconf" {
		t.Fatalf("upstream saw %q, want /paulca/awesomeconf — the prefix must NOT be stripped", body)
	}
}

// TestPathPreservesInboundHost: the upstream sees the hostname the browser
// asked for, matching the alias contract. Tito Go must be able to build
// absolute URLs that keep the visitor on ti.to.
func TestPathPreservesInboundHost(t *testing.T) {
	classic, _ := hostEchoBackend(t, "classic")
	golive, seenHost := hostEchoBackend(t, "go")
	reg := openReg(t)
	if err := reg.Upsert(registry.Route{
		Host: "ti.to", Kind: "service",
		Addr: classic.Listener.Addr().String(), Status: "ready",
	}); err != nil {
		t.Fatal(err)
	}
	if err := reg.Upsert(registry.Route{
		Host: "go.tito.io", Kind: "service",
		Addr: golive.Listener.Addr().String(), Status: "ready",
	}); err != nil {
		t.Fatal(err)
	}
	upsertPath(t, reg, "ti.to", "paulca", "go.tito.io")

	e, err := New(reg, nil)
	if err != nil {
		t.Fatal(err)
	}
	srv := httptest.NewServer(e)
	t.Cleanup(srv.Close)

	getHostPath(t, srv.URL, "ti.to", "/paulca")
	if *seenHost != "ti.to" {
		t.Fatalf("upstream saw Host %q, want ti.to", *seenHost)
	}
}

// TestPathRowForMissingTargetFallsThrough: a row whose target is not in the
// live map must serve the hostname's own route, never 404. This is the
// failure direction the whole design depends on.
func TestPathRowForMissingTargetFallsThrough(t *testing.T) {
	classic, _ := hostEchoBackend(t, "classic")
	reg := openReg(t)
	if err := reg.Upsert(registry.Route{
		Host: "ti.to", Kind: "service",
		Addr: classic.Listener.Addr().String(), Status: "ready",
	}); err != nil {
		t.Fatal(err)
	}
	upsertPath(t, reg, "ti.to", "paulca", "vanished.example")

	e, err := New(reg, nil)
	if err != nil {
		t.Fatal(err)
	}
	srv := httptest.NewServer(e)
	t.Cleanup(srv.Close)

	code, body := getHostPath(t, srv.URL, "ti.to", "/paulca")
	if code != http.StatusOK || body != "classic" {
		t.Fatalf("status=%d body=%q, want 200/classic — a dangling target must fall through", code, body)
	}
}

// TestHostsWithoutPathsAreUnaffected: the additive guarantee. Every host on
// the platform today has no prefix map and must behave exactly as before.
func TestHostsWithoutPathsAreUnaffected(t *testing.T) {
	backend, _ := hostEchoBackend(t, "plain")
	reg := openReg(t)
	if err := reg.Upsert(registry.Route{
		Host: "app.acct.oncarlos.com", Kind: "service",
		Addr: backend.Listener.Addr().String(), Status: "ready",
	}); err != nil {
		t.Fatal(err)
	}
	e, err := New(reg, nil)
	if err != nil {
		t.Fatal(err)
	}
	srv := httptest.NewServer(e)
	t.Cleanup(srv.Close)

	for _, p := range []string{"/", "/anything", "/deep/path/here"} {
		code, body := getHostPath(t, srv.URL, "app.acct.oncarlos.com", p)
		if code != http.StatusOK || body != "plain" {
			t.Fatalf("%s: status=%d body=%q, want 200/plain", p, code, body)
		}
	}
}

// TestFirstSegment is the matcher itself, tested directly because every
// routing decision in this feature rests on it.
func TestFirstSegment(t *testing.T) {
	for _, tc := range []struct{ in, want string }{
		{"/paulca", "paulca"},
		{"/paulca/", "paulca"},
		{"/paulca/awesomeconf", "paulca"},
		{"/paulca/awesomeconf/admin", "paulca"},
		{"/", ""},
		{"", ""},
		{"paulca", ""}, // not an absolute path; never matches
		{"//", ""},
	} {
		if got := firstSegment(tc.in); got != tc.want {
			t.Fatalf("firstSegment(%q) = %q, want %q", tc.in, got, tc.want)
		}
	}
}
```

Add the request helper to the same file if `getHost` in `alias_test.go` does not already take a path — check first and reuse it if it does:

```go
// getHostPath issues GET path against srv with an explicit Host header.
func getHostPath(t *testing.T, srvURL, host, path string) (int, string) {
	t.Helper()
	req, err := http.NewRequest("GET", srvURL+path, nil)
	if err != nil {
		t.Fatal(err)
	}
	req.Host = host
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	b, err := io.ReadAll(resp.Body)
	if err != nil {
		t.Fatal(err)
	}
	return resp.StatusCode, string(b)
}
```

(Import `io`.)

- [ ] **Step 2: Run the tests to verify they fail**

Run: `go test ./internal/edge/ -run 'TestPath|TestFirstSegment|TestHostsWithoutPaths' -count=1`
Expected: FAIL — `undefined: firstSegment`, and the routing tests serve "classic" for `/flipped`.

- [ ] **Step 3: Add the `paths` field to `upstream`**

In `internal/edge/edge.go`, inside the `upstream` struct after `alias`:

```go
	// paths, when non-nil, splits this hostname's traffic by leading URL
	// segment: a bare first segment ("paulca") mapped to the HOSTNAME of the
	// route that serves it, resolved against the same live map at request
	// time. Nil for every host that does not use prefix routing, which is
	// every host on the platform until a claim publishes a map — the nil
	// check in lookupUpstreamPath is the entire cost this feature imposes on
	// everyone else.
	//
	// Hostnames rather than resolved upstreams, deliberately: an upstream
	// captured here would be a stale copy of the target's entry the moment
	// the target's route changed within the same map build.
	paths map[string]string
```

- [ ] **Step 4: Fold `route_paths` into `Reload`**

In `Reload`, immediately BEFORE the `aliases, err := e.reg.ListAliases()` block (line 591):

```go
	// Path routes, folded BEFORE aliases so an alias of a prefix-routed
	// hostname inherits its map along with everything else it copies — an
	// alias is meant to be indistinguishable from its primary, and a
	// hostname that routed by prefix under one name but not the other would
	// be a trap.
	//
	// Targets are recorded as hostnames, not resolved here: every route is
	// already in m by this point, but resolving now would freeze a copy of
	// the target's entry, and the target's own row may still be rewritten by
	// the alias fold below.
	paths, err := e.reg.ListPaths()
	if err != nil {
		return err
	}
	for _, p := range paths {
		up, ok := m[p.Host]
		if !ok {
			// The hostname has no route on this box. The reconcile pass
			// should not have produced this row; nothing to attach it to.
			continue
		}
		if up.paths == nil {
			up.paths = make(map[string]string)
		}
		up.paths[p.Prefix] = p.Target
		m[p.Host] = up
	}
```

- [ ] **Step 5: Split the lookup and add the path-aware form**

Replace `lookupUpstream` (line 866) with the pair below. The existing doc comment above it stays exactly as it is — it describes `lookupIn`'s wildcard rule, which is unchanged — so move it onto `lookupIn`.

```go
func lookupIn(m map[string]upstream, host string) (upstream, bool) {
	if up, ok := m[host]; ok {
		return up, true
	}
	if i := strings.IndexByte(host, '.'); i > 0 {
		if up, ok := m["*"+host[i:]]; ok {
			return up, true
		}
	}
	return upstream{}, false
}

// lookupUpstream resolves a hostname alone. Kept as the primitive because not
// every caller is answering "where does this REQUEST go" — the routability
// check in the ACME/HostPolicy path asks only whether this box serves the
// hostname at all, and giving that question a path would make a hostname's
// routability depend on which URL happened to ask.
func (e *Edge) lookupUpstream(host string) (upstream, bool) {
	return lookupIn(e.current(), host)
}

// lookupUpstreamPath resolves a request: the hostname exactly as
// lookupUpstream does, then — only for a hostname carrying a prefix map — the
// first path segment.
//
// Both lookups read ONE snapshot of the map. Calling lookupUpstream and then
// current() again would let a reload land between them and resolve a prefix
// against a different table than the host came from.
//
// Every miss falls through to the hostname's own route rather than failing:
// an unlisted prefix, a dangling target, a request for "/". A prefix map is a
// migration in progress, and the un-migrated answer is always the safe one.
func (e *Edge) lookupUpstreamPath(host, path string) (upstream, bool) {
	m := e.current()
	up, ok := lookupIn(m, host)
	if !ok || up.paths == nil {
		return up, ok
	}
	seg := firstSegment(path)
	if seg == "" {
		return up, true
	}
	target, ok := up.paths[seg]
	if !ok {
		return up, true
	}
	tu, ok := m[target]
	if !ok {
		return up, true
	}
	return tu, true
}

// firstSegment returns the first path segment of an absolute URL path,
// without slashes: "/paulca/awesomeconf" -> "paulca". "" for "/", for the
// empty string, and for anything not starting with "/".
//
// Returning the segment rather than testing a string prefix is what makes
// matching segment-bounded: "/paulcarson" yields "paulcarson", which is not
// equal to "paulca", so a longer account name can never be dragged across a
// migration by a shorter one.
func firstSegment(p string) string {
	if p == "" || p[0] != '/' {
		return ""
	}
	p = p[1:]
	if i := strings.IndexByte(p, '/'); i >= 0 {
		p = p[:i]
	}
	return p
}
```

- [ ] **Step 6: Use the path-aware form in `ServeHTTP`**

At `internal/edge/edge.go:930`, change:

```go
	up, ok := e.lookupUpstream(host)
```

to:

```go
	up, ok := e.lookupUpstreamPath(host, r.URL.Path)
```

Leave the call at `edge.go:1455` alone — that is the routability check the doc comment above `lookupUpstream` describes.

Do not move this call within `ServeHTTP`. Its position after `acceptHop` is load-bearing: a backhauled request must resolve its prefix exactly once, on the box that ultimately serves it.

- [ ] **Step 7: Run the tests to verify they pass**

Run: `go test ./internal/edge/ -count=1`
Expected: PASS, including every existing edge test — especially the alias tests, which prove the alias fold still behaves.

- [ ] **Step 8: Verify the whole build is clean**

Run: `go build ./... && go vet ./... && gofmt -l . && go test ./... -count=1`
Expected: all clean.

- [ ] **Step 9: Commit**

```bash
git add internal/edge/edge.go internal/edge/path_test.go
git commit -m "edge: resolve a leading path segment for hosts that carry a prefix map

Host resolution is unchanged and stays the primitive: the routability check
in the cert path asks only whether this box serves a hostname, and must not
gain a path. Request dispatch gets a path-aware wrapper that reads one
snapshot of the map for both lookups.

Matching is segment-wise equality, so /paulcarson is never dragged across a
migration by /paulca, and the prefix is not stripped, so the flipped and
unflipped URL spaces stay identical. Every miss — unlisted prefix, dangling
target, bare / — falls through to the hostname's own route.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```

---

### Task 5: The write door and the read surfaces

**Files:**
- Modify: `internal/console/apidomains.go` (new handler, alongside the capabilities handler ~line 482)
- Modify: `internal/console/authcli.go` (route registration, ~line 624)
- Modify: `routes_cli.go` (path rows in `carlos routes`)
- Test: `internal/console/apidomainpaths_test.go` (create)

**Interfaces:**
- Consumes: `domains.SetClaimPaths`, `domains.ErrTooManyPaths`, `domains.ErrBadPathPrefix`, `domains.MaxPathsPerClaim` (Task 2).
- Produces: `PUT /api/cli/apps/{account}/{app}/domains/{host}/paths`.

- [ ] **Step 1: Write the failing tests**

Create `internal/console/apidomainpaths_test.go`. Read `internal/console/apidomains_test.go` first and reuse its server/auth harness verbatim — the exact helper names differ by file and must not be guessed.

Cover, using that harness:

1. **`TestAPIDomainPathsPublishes`** — `PUT` with body `{"paths":{"paulca":"go.tito.io"}}` returns 200, and `domains.GetClaim` shows the map.
2. **`TestAPIDomainPathsReplaces`** — a second `PUT` with `{"paths":{"bigcorp":"go.tito.io"}}` leaves `bigcorp` only; `paulca` is gone.
3. **`TestAPIDomainPathsEmptyBodyClears`** — `{"paths":{}}` clears the map and returns 200.
4. **`TestAPIDomainPathsRejectsOtherAccount`** — a principal scoped to another account gets the family's byte-identical 404, not a 403.
5. **`TestAPIDomainPathsRejectsOversize`** — a map above `domains.MaxPathsPerClaim` returns 422.
6. **`TestAPIDomainPathsRejectsBadPrefix`** — `{"paths":{"/paulca":"go.tito.io"}}` returns 422.

Each asserts the status code and, where it applies, reads the claim back to prove the effect.

- [ ] **Step 2: Run the tests to verify they fail**

Run: `go test ./internal/console/ -run TestAPIDomainPaths -count=1`
Expected: FAIL — 404 from an unregistered route.

- [ ] **Step 3: Add the handler**

In `internal/console/apidomains.go`, following the shape of the capabilities handler:

```go
// handleAPIDomainPaths answers `PUT /api/cli/apps/{account}/{app}/domains/{host}/paths`:
// publish the WHOLE prefix map for a hostname the app already claims.
//
// Wholesale by contract. The caller regenerates the map from its own database
// and publishes the result, so this replaces rather than merges — that is what
// makes a republish a full repair and lets a removal express a rollback. There
// is deliberately no per-prefix add or delete verb: two ways to write the same
// state is two ways for it to drift from the caller's own.
func (s *Server) handleAPIDomainPaths(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	acct, app, ok := s.apiAppScope(w, r) // the family's existing scope helper
	if !ok {
		return
	}
	host := r.PathValue("host")

	var body struct {
		Paths map[string]string `json:"paths"`
	}
	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<20)).Decode(&body); err != nil {
		jsonError(w, http.StatusBadRequest, "could not parse the request body as JSON")
		return
	}

	changed, err := domains.SetClaimPaths(ctx, s.cfg.Releases, host, acct.Sqid, app, body.Paths)
	if errors.Is(err, domains.ErrTooManyPaths) {
		jsonError(w, http.StatusUnprocessableEntity,
			fmt.Sprintf("a hostname may carry at most %d path prefixes", domains.MaxPathsPerClaim))
		return
	} else if errors.Is(err, domains.ErrBadPathPrefix) {
		jsonError(w, http.StatusUnprocessableEntity,
			"each path prefix must be a single bare segment: \"paulca\", not \"/paulca\"")
		return
	} else if errors.Is(err, domains.ErrHostUnavailable) {
		// The family's byte-identical 404 discipline: a caller who does not
		// hold this claim learns nothing about whether it exists.
		http.NotFound(w, r)
		return
	} else if err != nil {
		log.Printf("console: api domains paths %s/%s %s: %v", acct.Sqid, app, host, err)
		jsonError(w, http.StatusBadGateway, "could not update the hostname's path routes — try again")
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"host":    host,
		"paths":   body.Paths,
		"changed": changed,
	})
}
```

Match `apiAppScope` and `writeJSON` to whatever the neighbouring handlers in this file actually call — read them and use the same names. The 8MB body cap is sized for the `MaxPathsPerClaim` ceiling with headroom.

- [ ] **Step 4: Register the route**

In `internal/console/authcli.go`, beside the other domain routes:

```go
	s.cliRoute("PUT /api/cli/apps/{account}/{app}/domains/{host}/paths", svcrole.Allow(svcrole.Publish), s.handleAPIDomainPaths)
```

`Publish` only, not `Instance`: publishing a prefix map is a deployment-shaped decision about where an account's traffic goes, and an instance-scoped token has no business making it.

- [ ] **Step 5: Run the tests to verify they pass**

Run: `go test ./internal/console/ -count=1`
Expected: PASS.

- [ ] **Step 6: Show path routes in `carlos routes`**

In `routes_cli.go`, after each host's row, print its prefixes indented — read the file's existing table-building code and follow it exactly:

```go
	// Path routes under their hostname: the flipped set has to be visible
	// from the command operators already use, or a migration is invisible
	// until something breaks.
	for _, p := range pathsFor(rt.Host) {
		fmt.Fprintf(tw, "  /%s\t\t-> %s\t\t\t\t\n", p.Prefix, p.Target)
	}
```

Source the rows from the same API response the command already reads; extend that response to carry them if it does not. Read-only — no write verb (see §8 of the spec).

- [ ] **Step 7: Run the full suite**

Run: `go build ./... && go vet ./... && gofmt -l . && go test ./... -count=1`
Expected: all clean.

- [ ] **Step 8: Commit**

```bash
git add internal/console/apidomains.go internal/console/authcli.go internal/console/apidomainpaths_test.go routes_cli.go
git commit -m "console: publish a hostname's prefix map; show path routes in carlos routes

PUT .../domains/{host}/paths replaces the whole map, reusing the domains
family's auth, account scoping and byte-identical 404 discipline. Publish
scope only — deciding where an account's traffic goes is not an
instance-scoped decision.

No write verb on the CLI: the tenant's platform is the source of truth, and a
second writer would drift from it the first time anyone used it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```

---

### Task 6: End-to-end proof

**Files:**
- Test: `internal/edge/path_e2e_test.go` (create)

**Interfaces:**
- Consumes: everything from Tasks 1-4.
- Produces: nothing. This is the deliverable that says the machinery works.

This is the acceptance bar from spec §10. With `ti.to` not moving, nothing else proves the pieces compose — each earlier task tested its own layer against a hand-written input.

- [ ] **Step 1: Write the test**

Create `internal/edge/path_e2e_test.go`:

```go
package edge

import (
	"context"
	"net/http"
	"net/http/httptest"
	"testing"
	"time"

	"github.com/carlosframework/platform/internal/domains"
	"github.com/carlosframework/platform/internal/registry"
)

// TestPathFlipEndToEnd walks the whole chain a real flip takes — claim
// published, reconcile pass, fingerprint moves, edge reloads, traffic changes
// — and then walks it backwards. Each earlier task proved one layer against a
// hand-written input; this is the only test that proves they compose.
//
// The registry write and the edge reload are driven directly rather than
// waited on: the 2s tick is a latency bound, not a correctness property, and
// polling it would make this test slow and flaky. What must be true is that
// the fingerprint MOVES, which is asserted rather than assumed.
func TestPathFlipEndToEnd(t *testing.T) {
	ctx := context.Background()
	classic, _ := hostEchoBackend(t, "classic")
	golive, _ := hostEchoBackend(t, "go")

	reg := openReg(t)
	if err := reg.Upsert(registry.Route{
		Host: "ti.to", Kind: "service", Account: "tito", App: "classic",
		Addr: classic.Listener.Addr().String(), Status: "ready",
	}); err != nil {
		t.Fatal(err)
	}
	if err := reg.Upsert(registry.Route{
		Host: "go.tito.io", Kind: "service", Account: "tito", App: "go",
		Addr: golive.Listener.Addr().String(), Status: "ready",
	}); err != nil {
		t.Fatal(err)
	}

	store := newTestStore(t) // the in-memory release.Store the domains tests use
	if _, _, err := domains.AttachClaim(ctx, store, domains.Claim{
		Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
		Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z",
	}); err != nil {
		t.Fatal(err)
	}

	e, err := New(reg, nil)
	if err != nil {
		t.Fatal(err)
	}
	srv := httptest.NewServer(e)
	defer srv.Close()

	// Before the flip: everything is classic.
	if _, body := getHostPath(t, srv.URL, "ti.to", "/paulca/awesomeconf"); body != "classic" {
		t.Fatalf("before the flip: body = %q, want classic", body)
	}

	fpBefore, err := reg.Fingerprint()
	if err != nil {
		t.Fatal(err)
	}

	// The flip: Tito publishes its map, the pass folds it, the edge reloads.
	if _, err := domains.SetClaimPaths(ctx, store, "ti.to", "tito", "classic",
		map[string]string{"paulca": "go.tito.io"}); err != nil {
		t.Fatal(err)
	}
	if err := domains.ReconcileAliases(ctx, store, reg, time.Now, nil, nil); err != nil {
		t.Fatal(err)
	}
	fpAfter, err := reg.Fingerprint()
	if err != nil {
		t.Fatal(err)
	}
	if fpBefore == fpAfter {
		t.Fatal("fingerprint did not move across a flip — the edge's watcher would never reload")
	}
	if err := e.Reload(); err != nil {
		t.Fatal(err)
	}

	// After the flip: that account is on Go, its neighbours are not.
	if _, body := getHostPath(t, srv.URL, "ti.to", "/paulca/awesomeconf"); body != "go" {
		t.Fatalf("after the flip: body = %q, want go", body)
	}
	if _, body := getHostPath(t, srv.URL, "ti.to", "/someoneelse/event"); body != "classic" {
		t.Fatalf("after the flip: a neighbouring account moved too, body = %q", body)
	}
	if _, body := getHostPath(t, srv.URL, "ti.to", "/paulcarson"); body != "classic" {
		t.Fatalf("after the flip: a longer account name was dragged along, body = %q", body)
	}

	// The rollback: republish without that account.
	if _, err := domains.SetClaimPaths(ctx, store, "ti.to", "tito", "classic", nil); err != nil {
		t.Fatal(err)
	}
	if err := domains.ReconcileAliases(ctx, store, reg, time.Now, nil, nil); err != nil {
		t.Fatal(err)
	}
	if err := e.Reload(); err != nil {
		t.Fatal(err)
	}
	if _, body := getHostPath(t, srv.URL, "ti.to", "/paulca/awesomeconf"); body != "classic" {
		t.Fatalf("after the rollback: body = %q, want classic", body)
	}
	rows, err := reg.ListPaths()
	if err != nil {
		t.Fatal(err)
	}
	if len(rows) != 0 {
		t.Fatalf("after the rollback: %d path rows remain, want 0", len(rows))
	}
}
```

If `internal/edge` cannot import `internal/domains` without a cycle, put this test in a new `internal/pathrouting/e2e_test.go` package that imports both. Check with `go build ./...` before assuming either way.

- [ ] **Step 2: Run it**

Run: `go test ./internal/edge/ -run TestPathFlipEndToEnd -count=1 -v`
Expected: PASS.

- [ ] **Step 3: Run the full suite**

Run: `go build ./... && go vet ./... && gofmt -l . && go test ./... -count=1`
Expected: all clean.

- [ ] **Step 4: Commit**

```bash
git add internal/edge/path_e2e_test.go
git commit -m "test: end-to-end path flip and rollback

Walks claim -> reconcile -> fingerprint -> reload -> traffic, then walks it
back. With ti.to not moving behind the edge, this is the only thing that
proves the four layers compose rather than each working against a
hand-written input.

Asserts the fingerprint actually moves: a flip that reconciles correctly and
never reaches the live map is the failure this feature is most exposed to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```

- [ ] **Step 5: Open the PR**

```bash
git push -u origin HEAD
```

Then open a PR titled **"Path-based routing: one hostname, many upstreams"** whose body summarises the six tasks, links the spec, and carries the spec's §11 live checks as an explicit "not done, not claimed" list.

Note: at time of writing this checkout has only `origin` → amadan.net, with no `amadan` remote and no GitHub remote configured, so the PR may need a remote added first. Per AGENTS.md, CI runs on a push to `amadan` — a branch pushed only to `origin` has been gated by nothing.

---

## Self-Review

**Spec coverage**

| Spec section | Task |
|---|---|
| §2 prefix selects a route; bare segments | 1 (table), 4 (lookup) |
| §3 `Paths` claim field, wholesale publish, cap | 2 |
| §4 `route_paths`, pass-owned, tenancy gates, `Slug` untouched | 1, 3 |
| §5 `Fingerprint()` section + a test that the digest moves | 1 (unit), 6 (e2e) |
| §6 `lookupUpstream(host, path)`, no stripping, segment bounds, three call sites | 4 |
| §7 write door, `SetClaimPaths` single writer | 2, 5 |
| §8 read-only surfaces | 5 |
| §9 exclusions | Nothing implements them, by construction |
| §10 rollout stages 1-5 | Tasks 1-6 |
| §11 live checks | Carried into the PR body, not implemented |

Spec §4's ruling that `Route.Slug` stays untouched is honoured by no task mentioning it.

**Type consistency**

`RoutePath{Host, Prefix, Target, Account, App}` is used identically in Tasks 1, 3 and 4. `ListPaths`/`UpsertPath`/`DeletePath` keep their Task 1 signatures throughout. `SetClaimPaths(ctx, s, host, account, app, paths)` is called with that argument order in Tasks 2, 5 and 6. `firstSegment`, `lookupIn` and `lookupUpstreamPath` are defined once in Task 4 and used only there. `MaxPathsPerClaim` is the exported spelling everywhere (Tasks 2, 3, 5) — the spec's prose says `maxPathsPerClaim`, and exported wins because Task 5's console error message needs it across a package boundary.

**Known soft spots, called out rather than papered over**

- Task 5's tests and handler depend on `internal/console` helper names (`apiAppScope`, `writeJSON`) that must be read from the file rather than trusted from this plan.
- Task 6 may hit an import cycle; the fallback package is named in the step.
- Task 5 Step 6 needs `carlos routes`' existing response shape extended; the plan says to follow the file rather than prescribing a diff, because that command's plumbing was not read while writing this.
