carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

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:

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:

	// 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):

// 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:

	// 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:

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

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:

	// 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:

// 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:

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

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):

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:

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

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

		// 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:

	// 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
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:

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:

// 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:

	// 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):

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

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:

	up, ok := e.lookupUpstream(host)

to:

	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
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:

// 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:

	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:

	// 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
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:

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