carlos / platform Public
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_pathsrows 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 tomain. - 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 inmigrate()~line 395; newRoutePathtype and accessors afterDeleteAlias~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 throughListPaths.
- [ ] 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(Claimstruct ~line 34; newSetClaimPathsafterSetClaimCapabilities~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 readsClaim.Paths; Task 5 callsSetClaimPaths.
- [ ] 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
Pathsfield toClaim
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_pathsrows 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" + prefixThen 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 nilUpsertPath 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(upstreamstruct line 244;Reload's alias fold ~line 591-606;lookupUpstreamline 866;ServeHTTPdispatch line 930) - Test:
internal/edge/path_test.go(create)
Interfaces:
- Consumes:
registry.RoutePath,(*Registry).ListPaths(Task 1);route_pathsrows (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 outsideinternal/edgeconsumes 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
pathsfield toupstream
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_pathsintoReload
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 incarlos 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:
TestAPIDomainPathsPublishes—PUTwith body{"paths":{"paulca":"go.tito.io"}}returns 200, anddomains.GetClaimshows the map.TestAPIDomainPathsReplaces— a secondPUTwith{"paths":{"bigcorp":"go.tito.io"}}leavesbigcorponly;paulcais gone.TestAPIDomainPathsEmptyBodyClears—{"paths":{}}clears the map and returns 200.TestAPIDomainPathsRejectsOtherAccount— a principal scoped to another account gets the family's byte-identical 404, not a 403.TestAPIDomainPathsRejectsOversize— a map abovedomains.MaxPathsPerClaimreturns 422.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 HEADThen 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/consolehelper 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.