carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1# Path-Based Routing Implementation Plan
2
3> **HISTORICAL ARTIFACT — superseded by the code that shipped.** This plan was
4> written before implementation and is kept for the reasoning, not as a
5> description of the system. Its code blocks are NOT what shipped: implementers
6> found at least three defects in them (a missing serial bump on the write
7> door, a validation ordering in `SetClaimPaths`, and a wire shape the door
8> tightened to `{"paths": {...}}`). Read the design spec
9> (`docs/superpowers/specs/2026-09-02-path-based-routing-design.md`) for intent
10> and the source for behaviour; do not copy anything from here.
11
12> **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.
13
14**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.
15
16**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.
17
18**Tech Stack:** Go, SQLite (modernc `sqlite` driver), `net/http`, `net/http/httptest`. No new dependencies.
19
20**Spec:** `docs/superpowers/specs/2026-09-02-path-based-routing-design.md`
21
22## Global Constraints
23
24- **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.
25- **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.
26- **No prefix stripping.** The upstream receives the original path unchanged.
27- **`maxPathsPerClaim = 10000`.** Deterministic drops in sorted key order, logged, never silent.
28- **Tito is the only writer.** All CARLOS-side surfaces are read-only.
29- **CARLOS never learns Tito's account model.** No reserved-prefix list, no account validation, no URL-shape assumptions beyond "first segment".
30- **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`.
31- **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.
32
33---
34
35### Task 1: `route_paths` table, accessors, and the fingerprint section
36
37**Files:**
38- Modify: `internal/registry/registry.go` (schema in `migrate()` ~line 395; new `RoutePath` type and accessors after `DeleteAlias` ~line 1533; `Fingerprint()` ~line 1676)
39- Test: `internal/registry/paths_test.go` (create)
40
41**Interfaces:**
42- Consumes: nothing (first task).
43- 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`.
44
45- [ ] **Step 1: Write the failing tests**
46
47Create `internal/registry/paths_test.go`:
48
49```go
50package registry
51
52import "testing"
53
54// TestPathRoundTrip: a written prefix row comes back, and a repoint of the
55// same (host, prefix) replaces rather than duplicates.
56func TestPathRoundTrip(t *testing.T) {
57 reg := openTestRegistry(t)
58 if err := reg.UpsertPath(RoutePath{
59 Host: "ti.to", Prefix: "paulca", Target: "go.tito.io",
60 Account: "tito", App: "classic",
61 }); err != nil {
62 t.Fatal(err)
63 }
64 got, err := reg.ListPaths()
65 if err != nil {
66 t.Fatal(err)
67 }
68 if len(got) != 1 {
69 t.Fatalf("ListPaths returned %d rows, want 1", len(got))
70 }
71 if got[0].Target != "go.tito.io" {
72 t.Fatalf("target = %q, want go.tito.io", got[0].Target)
73 }
74
75 if err := reg.UpsertPath(RoutePath{
76 Host: "ti.to", Prefix: "paulca", Target: "beta.tito.io",
77 Account: "tito", App: "classic",
78 }); err != nil {
79 t.Fatal(err)
80 }
81 got, err = reg.ListPaths()
82 if err != nil {
83 t.Fatal(err)
84 }
85 if len(got) != 1 {
86 t.Fatalf("repoint produced %d rows, want 1 — (host, prefix) is the key", len(got))
87 }
88 if got[0].Target != "beta.tito.io" {
89 t.Fatalf("target = %q, want beta.tito.io after repoint", got[0].Target)
90 }
91}
92
93// TestDeletePathIsIdempotent: deleting a row that is already gone is a
94// success, the same contract DeleteAlias carries. The reconcile pass deletes
95// unconditionally, so this must not error.
96func TestDeletePathIsIdempotent(t *testing.T) {
97 reg := openTestRegistry(t)
98 if err := reg.DeletePath("ti.to", "nobody"); err != nil {
99 t.Fatalf("deleting an absent path row: %v", err)
100 }
101}
102
103// TestPathsAreScopedToTheirHost: two hosts may carry the same prefix without
104// colliding — the key is the pair, not the prefix.
105func TestPathsAreScopedToTheirHost(t *testing.T) {
106 reg := openTestRegistry(t)
107 for _, h := range []string{"ti.to", "other.example"} {
108 if err := reg.UpsertPath(RoutePath{
109 Host: h, Prefix: "paulca", Target: "go.tito.io",
110 Account: "tito", App: "classic",
111 }); err != nil {
112 t.Fatal(err)
113 }
114 }
115 got, err := reg.ListPaths()
116 if err != nil {
117 t.Fatal(err)
118 }
119 if len(got) != 2 {
120 t.Fatalf("got %d rows, want 2 — (host, prefix) is the key, not prefix alone", len(got))
121 }
122}
123
124// TestFingerprintChangesOnPathMutation is the load-bearing test of this
125// task. Fingerprint() is the 2s content hash the edge polls; a route_paths
126// change it cannot see is a flip that reconciles perfectly and never reaches
127// the live map — a silent false negative, the failure class Fingerprint's own
128// header names as the one to engineer against.
129func TestFingerprintChangesOnPathMutation(t *testing.T) {
130 reg := openTestRegistry(t)
131 if err := reg.UpsertPath(RoutePath{
132 Host: "ti.to", Prefix: "paulca", Target: "go.tito.io",
133 Account: "tito", App: "classic",
134 }); err != nil {
135 t.Fatal(err)
136 }
137 before, err := reg.Fingerprint()
138 if err != nil {
139 t.Fatal(err)
140 }
141 if err := reg.UpsertPath(RoutePath{
142 Host: "ti.to", Prefix: "paulca", Target: "beta.tito.io",
143 Account: "tito", App: "classic",
144 }); err != nil {
145 t.Fatal(err)
146 }
147 after, err := reg.Fingerprint()
148 if err != nil {
149 t.Fatal(err)
150 }
151 if before == after {
152 t.Fatalf("fingerprint unchanged across a route_paths repoint: %q", after)
153 }
154}
155
156// TestFingerprintChangesOnPathRemoval: the un-flip direction. Rollback is a
157// row disappearing, and it must reach the edge on the same 2s bound as the
158// flip did.
159func TestFingerprintChangesOnPathRemoval(t *testing.T) {
160 reg := openTestRegistry(t)
161 if err := reg.UpsertPath(RoutePath{
162 Host: "ti.to", Prefix: "paulca", Target: "go.tito.io",
163 Account: "tito", App: "classic",
164 }); err != nil {
165 t.Fatal(err)
166 }
167 before, err := reg.Fingerprint()
168 if err != nil {
169 t.Fatal(err)
170 }
171 if err := reg.DeletePath("ti.to", "paulca"); err != nil {
172 t.Fatal(err)
173 }
174 after, err := reg.Fingerprint()
175 if err != nil {
176 t.Fatal(err)
177 }
178 if before == after {
179 t.Fatalf("fingerprint unchanged across a route_paths removal: %q", after)
180 }
181}
182```
183
184- [ ] **Step 2: Run the tests to verify they fail**
185
186Run: `go test ./internal/registry/ -run 'TestPath|TestDeletePath|TestFingerprintChangesOnPath' -count=1`
187Expected: FAIL — `undefined: RoutePath`, `reg.UpsertPath undefined`.
188
189- [ ] **Step 3: Add the table to `migrate()`**
190
191In `internal/registry/registry.go`, immediately after the `wildcard_domains` `CREATE TABLE IF NOT EXISTS` block:
192
193```go
194 // route_paths is a derived table (the domains reconcile pass owns every
195 // row, materializing a claim's Paths map into it) — a plain CREATE TABLE
196 // IF NOT EXISTS, the same additive shape as route_aliases and
197 // wildcard_domains above, so existing DBs migrate on open with zero rows
198 // and no behaviour change.
199 //
200 // The key is the PAIR: `routes` has host as its primary key and must
201 // stay one row per hostname (that row owns the cert, the home box, the
202 // DNS state and the aliases), so a hostname's prefixes live here instead.
203 // prefix is a BARE segment — "paulca", never "/paulca".
204 if _, err := r.sql.Exec(`
205CREATE TABLE IF NOT EXISTS route_paths (
206 host TEXT NOT NULL,
207 prefix TEXT NOT NULL,
208 target TEXT NOT NULL,
209 account TEXT NOT NULL DEFAULT '',
210 app TEXT NOT NULL DEFAULT '',
211 PRIMARY KEY (host, prefix)
212);`); err != nil {
213 return err
214 }
215```
216
217- [ ] **Step 4: Add the type and accessors**
218
219In `internal/registry/registry.go`, after `DeleteAlias` (~line 1533):
220
221```go
222// RoutePath is one derived row of the route_paths table: a leading URL path
223// segment on Host that is served by the route at Target instead of by Host's
224// own route. Wholly pass-owned, like route_aliases and wildcard_domains:
225// every row traces to a claim's Paths entry, so removal needs no ownership
226// flag.
227//
228// A path row deliberately carries NO upstream, channel or environment of its
229// own. It is a pointer; everything about how the traffic is served comes from
230// Target's ordinary route row. That is what makes pointing a subset of
231// prefixes at a second route a working canary rather than a special case.
232//
233// Prefix is a bare segment — "paulca", never "/paulca".
234type RoutePath struct {
235 Host string
236 Prefix string
237 Target string
238 Account string
239 App string
240}
241
242// ListPaths returns every path route, ordered by host then prefix.
243func (r *Registry) ListPaths() ([]RoutePath, error) {
244 rows, err := r.sql.Query(`SELECT host, prefix, target, account, app
245FROM route_paths ORDER BY host, prefix`)
246 if err != nil {
247 return nil, err
248 }
249 defer rows.Close()
250 var out []RoutePath
251 for rows.Next() {
252 var p RoutePath
253 if err := rows.Scan(&p.Host, &p.Prefix, &p.Target, &p.Account, &p.App); err != nil {
254 return nil, err
255 }
256 out = append(out, p)
257 }
258 return out, rows.Err()
259}
260
261// UpsertPath inserts or repoints one path route by its (host, prefix) key.
262// Unlike UpsertAlias there are no stamp columns to preserve — nothing on this
263// table is maintained on a separate cadence — so every column is overwritten.
264func (r *Registry) UpsertPath(p RoutePath) error {
265 _, err := r.sql.Exec(`
266INSERT INTO route_paths (host, prefix, target, account, app)
267VALUES (?, ?, ?, ?, ?)
268ON CONFLICT(host, prefix) DO UPDATE SET
269 target=excluded.target, account=excluded.account, app=excluded.app`,
270 p.Host, p.Prefix, p.Target, p.Account, p.App)
271 return err
272}
273
274// DeletePath removes one path route. Idempotent: a row already gone is a
275// success, the same contract as DeleteAlias — the reconcile pass deletes
276// unconditionally from its own view of what is no longer desired.
277func (r *Registry) DeletePath(host, prefix string) error {
278 _, err := r.sql.Exec(`DELETE FROM route_paths WHERE host = ? AND prefix = ?`, host, prefix)
279 return err
280}
281```
282
283- [ ] **Step 5: Add the fingerprint section**
284
285In `Fingerprint()`, after the `wildcard_domains` section and before the final digest is returned:
286
287```go
288 // route_paths: which prefix on which hostname reaches which route is a
289 // routing decision in exactly the sense route_aliases' alias/host is, so
290 // it hashes here or a flip never reaches the live map. All five columns
291 // hash — unlike route_aliases there is no box-stamped, cadence-driven
292 // column on this table to exclude, so there is nothing here that could
293 // force a pointless fleet-wide reload.
294 fmt.Fprint(h, "\x1droute_paths\x1e")
295 prows, err := db.Query(`SELECT host, prefix, target, account, app
296FROM route_paths ORDER BY host, prefix`)
297 if err != nil {
298 return "", err
299 }
300 defer prows.Close()
301 for prows.Next() {
302 var host, prefix, target, account, app string
303 if err := prows.Scan(&host, &prefix, &target, &account, &app); err != nil {
304 return "", err
305 }
306 fmt.Fprintf(h, "%s\x1f%s\x1f%s\x1f%s\x1f%s\x1e", host, prefix, target, account, app)
307 }
308 if err := prows.Err(); err != nil {
309 return "", err
310 }
311```
312
313Also extend the `Fingerprint` doc comment, after the `wildcard_domains` paragraph:
314
315```go
316// route_paths is hashed for the same reload-latency reason, and it is the
317// section most worth stating explicitly: a path flip is the ONLY routing
318// change in the system whose whole observable effect is in this table. A
319// missing section here would not degrade a flip, it would make flips do
320// nothing at all — reconciled correctly, stored correctly, never served —
321// until some unrelated route write happened to move the digest.
322```
323
324- [ ] **Step 6: Run the tests to verify they pass**
325
326Run: `go test ./internal/registry/ -count=1`
327Expected: PASS, including the four new tests and every existing registry test.
328
329- [ ] **Step 7: Verify the whole build is clean**
330
331Run: `go build ./... && go vet ./... && gofmt -l . && go test ./... -count=1`
332Expected: no output from `gofmt -l .`, all tests pass. Zero `route_paths` rows exist anywhere, so nothing else can have changed behaviour.
333
334- [ ] **Step 8: Commit**
335
336```bash
337git add internal/registry/registry.go internal/registry/paths_test.go
338git commit -m "registry: route_paths table, accessors, and fingerprint section
339
340A hostname's routes table row is its identity — cert, home box, DNS state,
341aliases — so per-prefix routing lives in its own pass-owned table keyed on
342(host, prefix), mirroring route_aliases.
343
344The fingerprint section is the load-bearing half: a path flip's entire
345observable effect is in this table, so without it flips would reconcile
346correctly and never be served.
347
348Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
349```
350
351---
352
353### Task 2: `Claim.Paths` and `SetClaimPaths`
354
355**Files:**
356- Modify: `internal/domains/claims.go` (`Claim` struct ~line 34; new `SetClaimPaths` after `SetClaimCapabilities` ~line 317)
357- Test: `internal/domains/paths_test.go` (create)
358
359**Interfaces:**
360- Consumes: nothing from Task 1 (independent; may be built in parallel).
361- 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`.
362
363- [ ] **Step 1: Write the failing tests**
364
365Create `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.
366
367```go
368package domains
369
370import (
371 "context"
372 "errors"
373 "strconv"
374 "testing"
375)
376
377// TestSetClaimPathsRoundTrip: the map written is the map read back.
378func TestSetClaimPathsRoundTrip(t *testing.T) {
379 ctx := context.Background()
380 s := newTestStore(t) // see claims_test.go for the existing fake
381 if _, _, err := AttachClaim(ctx, s, Claim{
382 Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
383 Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z",
384 }); err != nil {
385 t.Fatal(err)
386 }
387 changed, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic",
388 map[string]string{"paulca": "go.tito.io"})
389 if err != nil {
390 t.Fatal(err)
391 }
392 if !changed {
393 t.Fatal("changed = false on the first write of a paths map")
394 }
395 c, err := GetClaim(ctx, s, "ti.to")
396 if err != nil {
397 t.Fatal(err)
398 }
399 if c.Paths["paulca"] != "go.tito.io" {
400 t.Fatalf("paths = %v, want paulca -> go.tito.io", c.Paths)
401 }
402}
403
404// TestSetClaimPathsIsWholesale: publishing is a REPLACE, not a merge. Tito
405// regenerates the whole map from its own database, so an account it no longer
406// lists must disappear — that is how a rollback reaches the edge.
407func TestSetClaimPathsIsWholesale(t *testing.T) {
408 ctx := context.Background()
409 s := newTestStore(t)
410 if _, _, err := AttachClaim(ctx, s, Claim{
411 Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
412 Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z",
413 }); err != nil {
414 t.Fatal(err)
415 }
416 if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic",
417 map[string]string{"paulca": "go.tito.io", "bigcorp": "go.tito.io"}); err != nil {
418 t.Fatal(err)
419 }
420 if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic",
421 map[string]string{"paulca": "go.tito.io"}); err != nil {
422 t.Fatal(err)
423 }
424 c, err := GetClaim(ctx, s, "ti.to")
425 if err != nil {
426 t.Fatal(err)
427 }
428 if _, still := c.Paths["bigcorp"]; still {
429 t.Fatal("bigcorp survived a republish that omitted it — publishing must replace, not merge")
430 }
431}
432
433// TestSetClaimPathsEmptyClearsTheMap: the final cutover. When every account
434// is across, Tito repoints the host's own route and publishes an empty map.
435func TestSetClaimPathsEmptyClearsTheMap(t *testing.T) {
436 ctx := context.Background()
437 s := newTestStore(t)
438 if _, _, err := AttachClaim(ctx, s, Claim{
439 Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
440 Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z",
441 }); err != nil {
442 t.Fatal(err)
443 }
444 if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic",
445 map[string]string{"paulca": "go.tito.io"}); err != nil {
446 t.Fatal(err)
447 }
448 if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", nil); err != nil {
449 t.Fatal(err)
450 }
451 c, err := GetClaim(ctx, s, "ti.to")
452 if err != nil {
453 t.Fatal(err)
454 }
455 if len(c.Paths) != 0 {
456 t.Fatalf("paths = %v, want empty after publishing an empty map", c.Paths)
457 }
458}
459
460// TestSetClaimPathsIsOwnerGated: a caller who does not hold the claim gets
461// the same neutral ErrHostUnavailable the attach door uses — no existence
462// oracle, byte-identical to the answer for a claim that does not exist.
463func TestSetClaimPathsIsOwnerGated(t *testing.T) {
464 ctx := context.Background()
465 s := newTestStore(t)
466 if _, _, err := AttachClaim(ctx, s, Claim{
467 Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
468 Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z",
469 }); err != nil {
470 t.Fatal(err)
471 }
472 if _, err := SetClaimPaths(ctx, s, "ti.to", "someone-else", "classic",
473 map[string]string{"paulca": "evil.example"}); !errors.Is(err, ErrHostUnavailable) {
474 t.Fatalf("err = %v, want ErrHostUnavailable for a non-owner", err)
475 }
476 if _, err := SetClaimPaths(ctx, s, "never-claimed.example", "tito", "classic",
477 map[string]string{"a": "b"}); !errors.Is(err, ErrHostUnavailable) {
478 t.Fatalf("err = %v, want the identical ErrHostUnavailable for an absent claim", err)
479 }
480}
481
482// TestSetClaimPathsUnchangedIsNoWrite: republishing an identical map must not
483// burn a bucket write. Tito republishes wholesale as a repair, so the no-op
484// case is the common one.
485func TestSetClaimPathsUnchangedIsNoWrite(t *testing.T) {
486 ctx := context.Background()
487 s := newTestStore(t)
488 if _, _, err := AttachClaim(ctx, s, Claim{
489 Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
490 Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z",
491 }); err != nil {
492 t.Fatal(err)
493 }
494 m := map[string]string{"paulca": "go.tito.io"}
495 if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", m); err != nil {
496 t.Fatal(err)
497 }
498 changed, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", m)
499 if err != nil {
500 t.Fatal(err)
501 }
502 if changed {
503 t.Fatal("changed = true for an identical republish — a no-op must not write")
504 }
505}
506
507// TestSetClaimPathsRefusesOversizeMap: the cap is enforced at the door, so an
508// oversize map is refused whole rather than silently truncated into the
509// bucket. (The reconcile pass caps again, defensively, for hand-written
510// claims — see Task 3.)
511func TestSetClaimPathsRefusesOversizeMap(t *testing.T) {
512 ctx := context.Background()
513 s := newTestStore(t)
514 if _, _, err := AttachClaim(ctx, s, Claim{
515 Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
516 Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z",
517 }); err != nil {
518 t.Fatal(err)
519 }
520 big := make(map[string]string, MaxPathsPerClaim+1)
521 for i := 0; i <= MaxPathsPerClaim; i++ {
522 big["acct"+strconv.Itoa(i)] = "go.tito.io"
523 }
524 if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", big); !errors.Is(err, ErrTooManyPaths) {
525 t.Fatalf("err = %v, want ErrTooManyPaths", err)
526 }
527}
528
529// TestSetClaimPathsRejectsMalformedPrefixes: a prefix is a bare segment. A
530// leading slash, an embedded slash, or an empty key would each produce a row
531// the edge could never match, so they are refused at the door rather than
532// stored as dead weight.
533func TestSetClaimPathsRejectsMalformedPrefixes(t *testing.T) {
534 ctx := context.Background()
535 s := newTestStore(t)
536 if _, _, err := AttachClaim(ctx, s, Claim{
537 Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
538 Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z",
539 }); err != nil {
540 t.Fatal(err)
541 }
542 for _, bad := range []string{"/paulca", "paulca/awesomeconf", "", "with space"} {
543 if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic",
544 map[string]string{bad: "go.tito.io"}); !errors.Is(err, ErrBadPathPrefix) {
545 t.Fatalf("prefix %q: err = %v, want ErrBadPathPrefix", bad, err)
546 }
547 }
548}
549```
550
551- [ ] **Step 2: Run the tests to verify they fail**
552
553Run: `go test ./internal/domains/ -run TestSetClaimPaths -count=1`
554Expected: FAIL — `undefined: SetClaimPaths`, `undefined: MaxPathsPerClaim`.
555
556- [ ] **Step 3: Add the `Paths` field to `Claim`**
557
558In `internal/domains/claims.go`, inside the `Claim` struct, after `Catchall`:
559
560```go
561 // Paths splits this hostname's traffic by leading URL segment: a bare
562 // first segment ("paulca", never "/paulca") mapped to the HOSTNAME of the
563 // route that serves it. Absent or empty means this host routes by
564 // hostname alone — the behaviour of every host today, and the reason this
565 // field is additive in the strongest sense: no row, no map, no change.
566 //
567 // This is a THIRD claim capability alongside Wildcard/Catchall, and it is
568 // declared here for the reason stated above them — converge preserves only
569 // DECLARED fields, so an undeclared field would be stripped by any binary
570 // that rewrote the claim.
571 //
572 // The writer is the tenant's own platform, publishing the whole map
573 // regenerated from its own database. Deltas were rejected deliberately: a
574 // lost delta desyncs the two systems silently, whereas a wholesale
575 // republish is always a full repair.
576 Paths map[string]string `json:"paths,omitempty"`
577```
578
579- [ ] **Step 4: Add the errors, the cap, and `SetClaimPaths`**
580
581In `internal/domains/claims.go`, after `SetClaimCapabilities`:
582
583```go
584// MaxPathsPerClaim bounds a claim's prefix map. Unlike maxWildcardDomains
585// this defends no external budget — there is no ACME equivalent for a path
586// row — it bounds the claim object's size and the per-box fold that reads it
587// every fast-converge tick. At roughly 40 bytes an entry this is a ~400KB
588// object refolded every couple of seconds, which is comfortable; sharding a
589// host's map across objects is deliberately not built until a real host
590// approaches this number.
591const MaxPathsPerClaim = 10000
592
593// ErrTooManyPaths is returned by SetClaimPaths for a map above
594// MaxPathsPerClaim. Refused whole: a silently truncated map would route a
595// subset of accounts and look successful.
596var ErrTooManyPaths = errors.New("too many path prefixes")
597
598// ErrBadPathPrefix is returned by SetClaimPaths for a key that is not a bare
599// single path segment.
600var ErrBadPathPrefix = errors.New("path prefix must be a single bare segment")
601
602// validPathPrefix reports whether p is a bare first path segment: non-empty,
603// no slashes, and nothing that would not survive a URL path intact. The edge
604// matches the request's first segment by EQUALITY, so anything that cannot be
605// a first segment could never match and must not be stored.
606func validPathPrefix(p string) bool {
607 if p == "" || len(p) > 255 {
608 return false
609 }
610 for i := 0; i < len(p); i++ {
611 c := p[i]
612 switch {
613 case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
614 case c == '-' || c == '_' || c == '.' || c == '~':
615 default:
616 return false
617 }
618 }
619 // "." and ".." would be normalised away by any path cleaner between the
620 // browser and here, so a row keyed on either is unreachable by
621 // construction.
622 return p != "." && p != ".."
623}
624
625// SetClaimPaths is the ONLY writer of a claim's Paths field, the same
626// single-writer discipline SetClaimCapabilities holds over Wildcard/Catchall
627// and for the same reason: AttachClaim's converge repoints Route and nothing
628// else, so it can preserve this map but must never invent or strip it.
629//
630// The write is WHOLESALE — paths replaces the stored map rather than merging
631// into it. That is the contract the caller's platform relies on: it
632// regenerates the map from its own database and publishes the result, so an
633// entry it no longer lists must disappear. Merging would make rollback
634// impossible to express.
635//
636// Owner-gated exactly as SetClaimCapabilities is: a caller who does not hold
637// the claim gets the neutral ErrHostUnavailable, and a missing claim answers
638// identically, so this door is not an existence oracle.
639func SetClaimPaths(ctx context.Context, s release.Store, host, account, app string, paths map[string]string) (changed bool, err error) {
640 if len(paths) > MaxPathsPerClaim {
641 return false, ErrTooManyPaths
642 }
643 for prefix, target := range paths {
644 if !validPathPrefix(prefix) {
645 return false, ErrBadPathPrefix
646 }
647 // The target must be a plausible hostname. Whether it is a route this
648 // account owns is decided by the reconcile pass, which — unlike this
649 // door — can see the registry.
650 if n, ok := NormalizeAliasHost(target); !ok || n != target {
651 return false, fmt.Errorf("domains: path prefix %q: target %q is not a normalised hostname", prefix, target)
652 }
653 }
654 for attempt := 0; ; attempt++ {
655 cur, etag, err := getClaimETag(ctx, s, host)
656 if errors.Is(err, os.ErrNotExist) {
657 return false, ErrHostUnavailable
658 } else if err != nil {
659 return false, err
660 }
661 if cur.Account != account || cur.App != app {
662 return false, ErrHostUnavailable
663 }
664 if samePaths(cur.Paths, paths) {
665 return false, nil
666 }
667 next := *cur
668 if len(paths) == 0 {
669 next.Paths = nil // omitempty: an empty map and no map are one state
670 } else {
671 next.Paths = make(map[string]string, len(paths))
672 for k, v := range paths {
673 next.Paths[k] = v
674 }
675 }
676 body, err := json.Marshal(next)
677 if err != nil {
678 return false, err
679 }
680 if _, err := s.Put(ctx, ClaimKey(host), body, etag); err == nil {
681 return true, nil
682 } else if errors.Is(err, release.ErrCASConflict) && attempt < 3 {
683 continue
684 } else {
685 return false, err
686 }
687 }
688}
689
690// samePaths reports whether two prefix maps are equal, treating nil and empty
691// as the same state. Republishing an unchanged map is the COMMON case — the
692// caller republishes wholesale as a repair — so this is what keeps a repair
693// from costing a bucket write and a fleet-wide edge reload.
694func samePaths(a, b map[string]string) bool {
695 if len(a) != len(b) {
696 return false
697 }
698 for k, v := range a {
699 if b[k] != v {
700 return false
701 }
702 }
703 return true
704}
705```
706
707Confirm `fmt` is imported in `claims.go` (it is — `getClaimETag` uses it).
708
709- [ ] **Step 5: Run the tests to verify they pass**
710
711Run: `go test ./internal/domains/ -count=1`
712Expected: PASS.
713
714- [ ] **Step 6: Verify converge round-trips the new field**
715
716`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:
717
718```go
719// TestAttachConvergePreservesPaths: a repoint must not strip the prefix map.
720func TestAttachConvergePreservesPaths(t *testing.T) {
721 ctx := context.Background()
722 s := newTestStore(t)
723 if _, _, err := AttachClaim(ctx, s, Claim{
724 Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
725 Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z",
726 }); err != nil {
727 t.Fatal(err)
728 }
729 if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic",
730 map[string]string{"paulca": "go.tito.io"}); err != nil {
731 t.Fatal(err)
732 }
733 if _, _, err := AttachClaim(ctx, s, Claim{
734 Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
735 Route: "ti-to-v2.tito.io", CreatedAt: "2026-09-03T00:00:00Z",
736 }); err != nil {
737 t.Fatal(err)
738 }
739 c, err := GetClaim(ctx, s, "ti.to")
740 if err != nil {
741 t.Fatal(err)
742 }
743 if c.Paths["paulca"] != "go.tito.io" {
744 t.Fatalf("paths = %v, want the map preserved across a converge repoint", c.Paths)
745 }
746}
747```
748
749Run: `go test ./internal/domains/ -count=1` — PASS.
750
751- [ ] **Step 7: Commit**
752
753```bash
754git add internal/domains/claims.go internal/domains/paths_test.go
755git commit -m "domains: claims carry a prefix map, written only by SetClaimPaths
756
757domains/<host>.json is already one CAS-written object per hostname carrying
758account and app — exactly the granularity and write semantics a per-host
759prefix map needs, so path routing is a third claim capability rather than a
760new control-plane object type.
761
762Writes are wholesale: the tenant's platform regenerates the map from its own
763database and publishes the result, so a republish is always a full repair and
764a lost update can never desync the two systems.
765
766Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
767```
768
769---
770
771### Task 3: Derive `route_paths` in the reconcile pass
772
773**Files:**
774- Modify: `internal/domains/aliases.go` (`ReconcileAliases`, line 48; the desired-set loop and the pass-owned deletion tail ~line 190-222)
775- Test: `internal/domains/aliases_paths_test.go` (create)
776
777**Interfaces:**
778- Consumes: `registry.RoutePath`, `(*Registry).ListPaths/UpsertPath/DeletePath` (Task 1); `Claim.Paths`, `MaxPathsPerClaim` (Task 2).
779- Produces: no new exported symbols. `route_paths` rows now exist in the registry for Task 4 to read.
780
781- [ ] **Step 1: Write the failing tests**
782
783Create `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.
784
785```go
786package domains
787
788import (
789 "context"
790 "strconv"
791 "testing"
792 "time"
793
794 "github.com/carlosframework/platform/internal/registry"
795)
796
797// pathsByPrefix collapses ListPaths into a prefix -> target map for one host.
798func pathsByPrefix(t *testing.T, reg *registry.Registry, host string) map[string]string {
799 t.Helper()
800 rows, err := reg.ListPaths()
801 if err != nil {
802 t.Fatal(err)
803 }
804 out := map[string]string{}
805 for _, p := range rows {
806 if p.Host == host {
807 out[p.Prefix] = p.Target
808 }
809 }
810 return out
811}
812
813// TestReconcileDerivesPathRows is the feature: a claim's Paths map becomes
814// route_paths rows.
815func TestReconcileDerivesPathRows(t *testing.T) {
816 ctx := context.Background()
817 s := newTestStore(t)
818 reg := newTestRegistry(t)
819 mustRoute(t, reg, "ti.to", "tito", "classic")
820 mustRoute(t, reg, "go.tito.io", "tito", "classic")
821 mustClaimWithPaths(t, ctx, s, "ti.to", "tito", "classic", "ti.to",
822 map[string]string{"paulca": "go.tito.io"})
823
824 if err := ReconcileAliases(ctx, s, reg, time.Now, nil, nil); err != nil {
825 t.Fatal(err)
826 }
827 if got := pathsByPrefix(t, reg, "ti.to"); got["paulca"] != "go.tito.io" {
828 t.Fatalf("route_paths = %v, want paulca -> go.tito.io", got)
829 }
830}
831
832// TestReconcileRemovesUndesiredPathRows is the rollback path and the
833// pass-ownership property: a prefix dropped from the published map disappears
834// from the table on the next pass, with no delete verb anywhere.
835func TestReconcileRemovesUndesiredPathRows(t *testing.T) {
836 ctx := context.Background()
837 s := newTestStore(t)
838 reg := newTestRegistry(t)
839 mustRoute(t, reg, "ti.to", "tito", "classic")
840 mustRoute(t, reg, "go.tito.io", "tito", "classic")
841 mustClaimWithPaths(t, ctx, s, "ti.to", "tito", "classic", "ti.to",
842 map[string]string{"paulca": "go.tito.io", "bigcorp": "go.tito.io"})
843 if err := ReconcileAliases(ctx, s, reg, time.Now, nil, nil); err != nil {
844 t.Fatal(err)
845 }
846 if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic",
847 map[string]string{"paulca": "go.tito.io"}); err != nil {
848 t.Fatal(err)
849 }
850 if err := ReconcileAliases(ctx, s, reg, time.Now, nil, nil); err != nil {
851 t.Fatal(err)
852 }
853 got := pathsByPrefix(t, reg, "ti.to")
854 if _, still := got["bigcorp"]; still {
855 t.Fatalf("route_paths = %v, want bigcorp gone after it left the published map", got)
856 }
857 if got["paulca"] != "go.tito.io" {
858 t.Fatalf("route_paths = %v, want paulca still routed", got)
859 }
860}
861
862// TestReconcileRefusesCrossTenantTarget is the one genuinely new authorization
863// rule in this feature: a prefix must never point another tenant's traffic at
864// your backend.
865func TestReconcileRefusesCrossTenantTarget(t *testing.T) {
866 ctx := context.Background()
867 s := newTestStore(t)
868 reg := newTestRegistry(t)
869 mustRoute(t, reg, "ti.to", "tito", "classic")
870 mustRoute(t, reg, "victim.example", "someone-else", "their-app")
871 mustClaimWithPaths(t, ctx, s, "ti.to", "tito", "classic", "ti.to",
872 map[string]string{"paulca": "victim.example"})
873
874 if err := ReconcileAliases(ctx, s, reg, time.Now, nil, nil); err != nil {
875 t.Fatal(err)
876 }
877 if got := pathsByPrefix(t, reg, "ti.to"); len(got) != 0 {
878 t.Fatalf("route_paths = %v, want no row — the target belongs to another account", got)
879 }
880}
881
882// TestReconcileSkipsUnknownTarget: a target that is not a local route yields
883// no row, so the prefix falls through to the host's own route. That is the
884// correct failure direction for a migration — a missing Go route serves
885// classic rather than 404ing.
886func TestReconcileSkipsUnknownTarget(t *testing.T) {
887 ctx := context.Background()
888 s := newTestStore(t)
889 reg := newTestRegistry(t)
890 mustRoute(t, reg, "ti.to", "tito", "classic")
891 mustClaimWithPaths(t, ctx, s, "ti.to", "tito", "classic", "ti.to",
892 map[string]string{"paulca": "not-deployed-here.example"})
893
894 if err := ReconcileAliases(ctx, s, reg, time.Now, nil, nil); err != nil {
895 t.Fatal(err)
896 }
897 if got := pathsByPrefix(t, reg, "ti.to"); len(got) != 0 {
898 t.Fatalf("route_paths = %v, want no row for a target that is not a local route", got)
899 }
900}
901
902// TestReconcileSkipsWhenClaimHostIsNotALocalRoute: the prefix map belongs to
903// the hostname's own route. If that route lives on another box, this box
904// derives nothing — the same per-box rule the alias half already follows.
905func TestReconcileSkipsWhenClaimHostIsNotALocalRoute(t *testing.T) {
906 ctx := context.Background()
907 s := newTestStore(t)
908 reg := newTestRegistry(t)
909 mustRoute(t, reg, "go.tito.io", "tito", "classic")
910 mustClaimWithPaths(t, ctx, s, "ti.to", "tito", "classic", "ti.to",
911 map[string]string{"paulca": "go.tito.io"})
912
913 if err := ReconcileAliases(ctx, s, reg, time.Now, nil, nil); err != nil {
914 t.Fatal(err)
915 }
916 if got := pathsByPrefix(t, reg, "ti.to"); len(got) != 0 {
917 t.Fatalf("route_paths = %v, want nothing — ti.to is not a route on this box", got)
918 }
919}
920
921// TestReconcileCapsPathRows: a hand-written claim above the cap is truncated
922// deterministically (sorted key order), never silently and never at random.
923func TestReconcileCapsPathRows(t *testing.T) {
924 ctx := context.Background()
925 s := newTestStore(t)
926 reg := newTestRegistry(t)
927 mustRoute(t, reg, "ti.to", "tito", "classic")
928 mustRoute(t, reg, "go.tito.io", "tito", "classic")
929 big := make(map[string]string, MaxPathsPerClaim+10)
930 for i := 0; i < MaxPathsPerClaim+10; i++ {
931 big["acct"+strconv.Itoa(i)] = "go.tito.io"
932 }
933 mustClaimWithPaths(t, ctx, s, "ti.to", "tito", "classic", "ti.to", big)
934
935 if err := ReconcileAliases(ctx, s, reg, time.Now, nil, nil); err != nil {
936 t.Fatal(err)
937 }
938 if got := pathsByPrefix(t, reg, "ti.to"); len(got) != MaxPathsPerClaim {
939 t.Fatalf("derived %d rows, want the cap of %d", len(got), MaxPathsPerClaim)
940 }
941}
942```
943
944Add these helpers to the same file (adapt the route/claim shapes to whatever the existing alias tests use):
945
946```go
947func mustRoute(t *testing.T, reg *registry.Registry, host, account, app string) {
948 t.Helper()
949 if err := reg.Upsert(registry.Route{
950 Host: host, Kind: "service", Addr: "127.0.0.1:1", Status: "ready",
951 Account: account, App: app,
952 }); err != nil {
953 t.Fatalf("upsert route %s: %v", host, err)
954 }
955}
956
957func mustClaimWithPaths(t *testing.T, ctx context.Context, s release.Store,
958 host, account, app, route string, paths map[string]string) {
959 t.Helper()
960 if _, _, err := AttachClaim(ctx, s, Claim{
961 Schema: 1, Host: host, Account: account, App: app, Route: route,
962 CreatedAt: "2026-09-03T00:00:00Z",
963 }); err != nil {
964 t.Fatalf("attach claim %s: %v", host, err)
965 }
966 // Written directly rather than through SetClaimPaths so the cap test can
967 // stage an oversize map the door would refuse.
968 cur, etag, err := getClaimETag(ctx, s, host)
969 if err != nil {
970 t.Fatal(err)
971 }
972 cur.Paths = paths
973 body, err := json.Marshal(cur)
974 if err != nil {
975 t.Fatal(err)
976 }
977 if _, err := s.Put(ctx, ClaimKey(host), body, etag); err != nil {
978 t.Fatal(err)
979 }
980}
981```
982
983Import `encoding/json` and `github.com/carlosframework/platform/internal/release` in the test file.
984
985- [ ] **Step 2: Run the tests to verify they fail**
986
987Run: `go test ./internal/domains/ -run TestReconcile -count=1`
988Expected: FAIL — the new tests find zero derived rows.
989
990- [ ] **Step 3: Derive the desired path set**
991
992In `internal/domains/aliases.go`, add `"sort"` to the imports. Alongside `desired` and `desiredWild` near the top of `ReconcileAliases`, add:
993
994```go
995 desiredPaths := map[string]registry.RoutePath{} // key: host + "\x00" + prefix
996```
997
998Then inside the `for _, c := range claims` loop, after the wildcard derivation, add:
999
1000```go
1001 // Path derivation (prefix routing). A claim's Paths map splits its own
1002 // hostname's traffic by leading URL segment; each entry names the
1003 // hostname of the route that serves that segment.
1004 //
1005 // Gated on the claim's host being a LOCAL route whose tenancy matches
1006 // the claim — the same per-box rule the alias half above follows, and
1007 // for the same reason: the prefix map belongs to that route, so a box
1008 // that does not serve the route derives nothing.
1009 if len(c.Paths) > 0 {
1010 hostRt, ok := byHost[c.Host]
1011 if !ok {
1012 // The hostname's own route is not on this box; another box
1013 // derives these rows.
1014 } else if hostRt.App != c.App || release.RouteAccount(hostRt) != c.Account {
1015 log.Printf("domains: paths on %s refused: route belongs to %s/%s, claim says %s/%s",
1016 c.Host, release.RouteAccount(hostRt), hostRt.App, c.Account, c.App)
1017 } else {
1018 // Sorted so the cap below truncates DETERMINISTICALLY: every
1019 // box must derive the identical subset, or the same URL would
1020 // resolve differently depending on which edge answered.
1021 prefixes := make([]string, 0, len(c.Paths))
1022 for p := range c.Paths {
1023 prefixes = append(prefixes, p)
1024 }
1025 sort.Strings(prefixes)
1026 if len(prefixes) > MaxPathsPerClaim {
1027 log.Printf("domains: paths on %s: %d prefixes exceeds the cap of %d, dropping %d in sorted order",
1028 c.Host, len(prefixes), MaxPathsPerClaim, len(prefixes)-MaxPathsPerClaim)
1029 prefixes = prefixes[:MaxPathsPerClaim]
1030 }
1031 for _, prefix := range prefixes {
1032 target := c.Paths[prefix]
1033 if !validPathPrefix(prefix) {
1034 log.Printf("domains: path %s/%s refused: not a bare path segment", c.Host, prefix)
1035 continue
1036 }
1037 tgtRt, ok := byHost[target]
1038 if !ok {
1039 // Target is not a route on this box. No row, so the
1040 // prefix falls through to the host's own route — the
1041 // right failure direction for a migration: a missing
1042 // destination serves the ORIGIN, never a 404.
1043 continue
1044 }
1045 // The authorization rule this feature adds: a prefix must
1046 // never point another tenant's traffic at your backend.
1047 // Account only — an account routing between its own apps
1048 // (classic and Go ARE two apps) is the whole use case.
1049 if release.RouteAccount(tgtRt) != c.Account {
1050 log.Printf("domains: path %s/%s refused: target %s belongs to %s, claim says %s",
1051 c.Host, prefix, target, release.RouteAccount(tgtRt), c.Account)
1052 continue
1053 }
1054 desiredPaths[c.Host+"\x00"+prefix] = registry.RoutePath{
1055 Host: c.Host, Prefix: prefix, Target: target,
1056 Account: c.Account, App: c.App,
1057 }
1058 }
1059 }
1060 }
1061```
1062
1063- [ ] **Step 4: Write and prune the table**
1064
1065At the end of `ReconcileAliases`, after the `wildcard_domains` deletion loop and before `return nil`:
1066
1067```go
1068 // route_paths is wholly pass-owned, like route_aliases and
1069 // wildcard_domains: every row traces to a claim's Paths entry, so a
1070 // prefix removed from a republished map disappears here with no delete
1071 // verb anywhere. That is exactly how a per-account rollback reaches the
1072 // edge.
1073 existingPaths, err := reg.ListPaths()
1074 if err != nil {
1075 return err
1076 }
1077 for _, p := range desiredPaths {
1078 if err := reg.UpsertPath(p); err != nil {
1079 log.Printf("domains: upsert path %s/%s: %v", p.Host, p.Prefix, err)
1080 }
1081 }
1082 for _, p := range existingPaths {
1083 if _, ok := desiredPaths[p.Host+"\x00"+p.Prefix]; !ok {
1084 if err := reg.DeletePath(p.Host, p.Prefix); err != nil {
1085 log.Printf("domains: remove path %s/%s: %v", p.Host, p.Prefix, err)
1086 }
1087 }
1088 }
1089 return nil
1090```
1091
1092`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.
1093
1094- [ ] **Step 5: Run the tests to verify they pass**
1095
1096Run: `go test ./internal/domains/ -count=1`
1097Expected: PASS, including every existing alias and wildcard test — the pass's existing behaviour must be untouched.
1098
1099- [ ] **Step 6: Commit**
1100
1101```bash
1102git add internal/domains/aliases.go internal/domains/aliases_paths_test.go
1103git commit -m "domains: derive route_paths from claims, gated on same-account targets
1104
1105Extends the pass that already materializes route_aliases and
1106wildcard_domains, keeping the same wholly-pass-owned property: a prefix
1107dropped from a republished map disappears on the next tick, so rollback needs
1108no delete verb.
1109
1110Adds one authorization rule — a prefix's target must belong to the claim's
1111account — so a prefix can never point another tenant's traffic at your
1112backend. A target that is not a local route yields no row, so the prefix falls
1113through to the hostname's own route: a missing destination serves the origin,
1114never a 404.
1115
1116Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
1117```
1118
1119---
1120
1121### Task 4: The edge resolves prefixes
1122
1123**Files:**
1124- Modify: `internal/edge/edge.go` (`upstream` struct line 244; `Reload`'s alias fold ~line 591-606; `lookupUpstream` line 866; `ServeHTTP` dispatch line 930)
1125- Test: `internal/edge/path_test.go` (create)
1126
1127**Interfaces:**
1128- Consumes: `registry.RoutePath`, `(*Registry).ListPaths` (Task 1); `route_paths` rows (Task 3).
1129- 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.
1130
1131- [ ] **Step 1: Write the failing tests**
1132
1133Create `internal/edge/path_test.go`:
1134
1135```go
1136package edge
1137
1138import (
1139 "net/http"
1140 "net/http/httptest"
1141 "testing"
1142
1143 "github.com/carlosframework/platform/internal/registry"
1144)
1145
1146// upsertPath writes one route_paths row, the shape the domains reconcile pass
1147// derives from a claim's Paths map.
1148func upsertPath(t *testing.T, reg *registry.Registry, host, prefix, target string) {
1149 t.Helper()
1150 if err := reg.UpsertPath(registry.RoutePath{
1151 Host: host, Prefix: prefix, Target: target, Account: "acct", App: "app",
1152 }); err != nil {
1153 t.Fatalf("upsert path %s/%s -> %s: %v", host, prefix, target, err)
1154 }
1155}
1156
1157// splitHost stands up one hostname whose default route is "classic" and whose
1158// "flipped" prefix reaches a second route, returning the live test server.
1159func splitHost(t *testing.T) *httptest.Server {
1160 t.Helper()
1161 classic, _ := hostEchoBackend(t, "classic")
1162 golive, _ := hostEchoBackend(t, "go")
1163 reg := openReg(t)
1164 if err := reg.Upsert(registry.Route{
1165 Host: "ti.to", Kind: "service",
1166 Addr: classic.Listener.Addr().String(), Status: "ready",
1167 }); err != nil {
1168 t.Fatal(err)
1169 }
1170 if err := reg.Upsert(registry.Route{
1171 Host: "go.tito.io", Kind: "service",
1172 Addr: golive.Listener.Addr().String(), Status: "ready",
1173 }); err != nil {
1174 t.Fatal(err)
1175 }
1176 upsertPath(t, reg, "ti.to", "flipped", "go.tito.io")
1177
1178 e, err := New(reg, nil)
1179 if err != nil {
1180 t.Fatalf("New: %v", err)
1181 }
1182 srv := httptest.NewServer(e)
1183 t.Cleanup(srv.Close)
1184 return srv
1185}
1186
1187// TestPathRouteReachesTarget is the feature: a flipped prefix reaches the
1188// other route's backend while the same hostname's other traffic does not.
1189func TestPathRouteReachesTarget(t *testing.T) {
1190 srv := splitHost(t)
1191 for _, tc := range []struct{ path, want string }{
1192 {"/flipped", "go"},
1193 {"/flipped/awesomeconf", "go"},
1194 {"/flipped/awesomeconf/admin", "go"},
1195 {"/notflipped", "classic"},
1196 {"/notflipped/event", "classic"},
1197 {"/", "classic"},
1198 {"", "classic"},
1199 } {
1200 code, body := getHostPath(t, srv.URL, "ti.to", tc.path)
1201 if code != http.StatusOK {
1202 t.Fatalf("%s: status = %d, want 200", tc.path, code)
1203 }
1204 if body != tc.want {
1205 t.Fatalf("%s: body = %q, want %q", tc.path, body, tc.want)
1206 }
1207 }
1208}
1209
1210// TestPathMatchIsSegmentBounded is the boundary pin. A prefix match must be
1211// segment-wise equality, never a string prefix — "flippedxyz" is a different
1212// account from "flipped" and must not be dragged across the migration with it.
1213func TestPathMatchIsSegmentBounded(t *testing.T) {
1214 srv := splitHost(t)
1215 for _, p := range []string{"/flippedxyz", "/flipped-two", "/flippedxyz/event"} {
1216 code, body := getHostPath(t, srv.URL, "ti.to", p)
1217 if code != http.StatusOK {
1218 t.Fatalf("%s: status = %d, want 200", p, code)
1219 }
1220 if body != "classic" {
1221 t.Fatalf("%s: body = %q, want classic — a prefix matches whole segments only", p, body)
1222 }
1223 }
1224}
1225
1226// TestPathRouteDoesNotStripThePrefix: the upstream sees the original URL. The
1227// flipped and unflipped URL spaces must be identical, which is the whole
1228// point of a seamless migration.
1229func TestPathRouteDoesNotStripThePrefix(t *testing.T) {
1230 classic, _ := hostEchoBackend(t, "classic")
1231 golive := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1232 w.Write([]byte(r.URL.Path))
1233 }))
1234 t.Cleanup(golive.Close)
1235
1236 reg := openReg(t)
1237 if err := reg.Upsert(registry.Route{
1238 Host: "ti.to", Kind: "service",
1239 Addr: classic.Listener.Addr().String(), Status: "ready",
1240 }); err != nil {
1241 t.Fatal(err)
1242 }
1243 if err := reg.Upsert(registry.Route{
1244 Host: "go.tito.io", Kind: "service",
1245 Addr: golive.Listener.Addr().String(), Status: "ready",
1246 }); err != nil {
1247 t.Fatal(err)
1248 }
1249 upsertPath(t, reg, "ti.to", "paulca", "go.tito.io")
1250
1251 e, err := New(reg, nil)
1252 if err != nil {
1253 t.Fatal(err)
1254 }
1255 srv := httptest.NewServer(e)
1256 t.Cleanup(srv.Close)
1257
1258 _, body := getHostPath(t, srv.URL, "ti.to", "/paulca/awesomeconf")
1259 if body != "/paulca/awesomeconf" {
1260 t.Fatalf("upstream saw %q, want /paulca/awesomeconf — the prefix must NOT be stripped", body)
1261 }
1262}
1263
1264// TestPathPreservesInboundHost: the upstream sees the hostname the browser
1265// asked for, matching the alias contract. Tito Go must be able to build
1266// absolute URLs that keep the visitor on ti.to.
1267func TestPathPreservesInboundHost(t *testing.T) {
1268 classic, _ := hostEchoBackend(t, "classic")
1269 golive, seenHost := hostEchoBackend(t, "go")
1270 reg := openReg(t)
1271 if err := reg.Upsert(registry.Route{
1272 Host: "ti.to", Kind: "service",
1273 Addr: classic.Listener.Addr().String(), Status: "ready",
1274 }); err != nil {
1275 t.Fatal(err)
1276 }
1277 if err := reg.Upsert(registry.Route{
1278 Host: "go.tito.io", Kind: "service",
1279 Addr: golive.Listener.Addr().String(), Status: "ready",
1280 }); err != nil {
1281 t.Fatal(err)
1282 }
1283 upsertPath(t, reg, "ti.to", "paulca", "go.tito.io")
1284
1285 e, err := New(reg, nil)
1286 if err != nil {
1287 t.Fatal(err)
1288 }
1289 srv := httptest.NewServer(e)
1290 t.Cleanup(srv.Close)
1291
1292 getHostPath(t, srv.URL, "ti.to", "/paulca")
1293 if *seenHost != "ti.to" {
1294 t.Fatalf("upstream saw Host %q, want ti.to", *seenHost)
1295 }
1296}
1297
1298// TestPathRowForMissingTargetFallsThrough: a row whose target is not in the
1299// live map must serve the hostname's own route, never 404. This is the
1300// failure direction the whole design depends on.
1301func TestPathRowForMissingTargetFallsThrough(t *testing.T) {
1302 classic, _ := hostEchoBackend(t, "classic")
1303 reg := openReg(t)
1304 if err := reg.Upsert(registry.Route{
1305 Host: "ti.to", Kind: "service",
1306 Addr: classic.Listener.Addr().String(), Status: "ready",
1307 }); err != nil {
1308 t.Fatal(err)
1309 }
1310 upsertPath(t, reg, "ti.to", "paulca", "vanished.example")
1311
1312 e, err := New(reg, nil)
1313 if err != nil {
1314 t.Fatal(err)
1315 }
1316 srv := httptest.NewServer(e)
1317 t.Cleanup(srv.Close)
1318
1319 code, body := getHostPath(t, srv.URL, "ti.to", "/paulca")
1320 if code != http.StatusOK || body != "classic" {
1321 t.Fatalf("status=%d body=%q, want 200/classic — a dangling target must fall through", code, body)
1322 }
1323}
1324
1325// TestHostsWithoutPathsAreUnaffected: the additive guarantee. Every host on
1326// the platform today has no prefix map and must behave exactly as before.
1327func TestHostsWithoutPathsAreUnaffected(t *testing.T) {
1328 backend, _ := hostEchoBackend(t, "plain")
1329 reg := openReg(t)
1330 if err := reg.Upsert(registry.Route{
1331 Host: "app.acct.oncarlos.com", Kind: "service",
1332 Addr: backend.Listener.Addr().String(), Status: "ready",
1333 }); err != nil {
1334 t.Fatal(err)
1335 }
1336 e, err := New(reg, nil)
1337 if err != nil {
1338 t.Fatal(err)
1339 }
1340 srv := httptest.NewServer(e)
1341 t.Cleanup(srv.Close)
1342
1343 for _, p := range []string{"/", "/anything", "/deep/path/here"} {
1344 code, body := getHostPath(t, srv.URL, "app.acct.oncarlos.com", p)
1345 if code != http.StatusOK || body != "plain" {
1346 t.Fatalf("%s: status=%d body=%q, want 200/plain", p, code, body)
1347 }
1348 }
1349}
1350
1351// TestFirstSegment is the matcher itself, tested directly because every
1352// routing decision in this feature rests on it.
1353func TestFirstSegment(t *testing.T) {
1354 for _, tc := range []struct{ in, want string }{
1355 {"/paulca", "paulca"},
1356 {"/paulca/", "paulca"},
1357 {"/paulca/awesomeconf", "paulca"},
1358 {"/paulca/awesomeconf/admin", "paulca"},
1359 {"/", ""},
1360 {"", ""},
1361 {"paulca", ""}, // not an absolute path; never matches
1362 {"//", ""},
1363 } {
1364 if got := firstSegment(tc.in); got != tc.want {
1365 t.Fatalf("firstSegment(%q) = %q, want %q", tc.in, got, tc.want)
1366 }
1367 }
1368}
1369```
1370
1371Add 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:
1372
1373```go
1374// getHostPath issues GET path against srv with an explicit Host header.
1375func getHostPath(t *testing.T, srvURL, host, path string) (int, string) {
1376 t.Helper()
1377 req, err := http.NewRequest("GET", srvURL+path, nil)
1378 if err != nil {
1379 t.Fatal(err)
1380 }
1381 req.Host = host
1382 resp, err := http.DefaultClient.Do(req)
1383 if err != nil {
1384 t.Fatal(err)
1385 }
1386 defer resp.Body.Close()
1387 b, err := io.ReadAll(resp.Body)
1388 if err != nil {
1389 t.Fatal(err)
1390 }
1391 return resp.StatusCode, string(b)
1392}
1393```
1394
1395(Import `io`.)
1396
1397- [ ] **Step 2: Run the tests to verify they fail**
1398
1399Run: `go test ./internal/edge/ -run 'TestPath|TestFirstSegment|TestHostsWithoutPaths' -count=1`
1400Expected: FAIL — `undefined: firstSegment`, and the routing tests serve "classic" for `/flipped`.
1401
1402- [ ] **Step 3: Add the `paths` field to `upstream`**
1403
1404In `internal/edge/edge.go`, inside the `upstream` struct after `alias`:
1405
1406```go
1407 // paths, when non-nil, splits this hostname's traffic by leading URL
1408 // segment: a bare first segment ("paulca") mapped to the HOSTNAME of the
1409 // route that serves it, resolved against the same live map at request
1410 // time. Nil for every host that does not use prefix routing, which is
1411 // every host on the platform until a claim publishes a map — the nil
1412 // check in lookupUpstreamPath is the entire cost this feature imposes on
1413 // everyone else.
1414 //
1415 // Hostnames rather than resolved upstreams, deliberately: an upstream
1416 // captured here would be a stale copy of the target's entry the moment
1417 // the target's route changed within the same map build.
1418 paths map[string]string
1419```
1420
1421- [ ] **Step 4: Fold `route_paths` into `Reload`**
1422
1423In `Reload`, immediately BEFORE the `aliases, err := e.reg.ListAliases()` block (line 591):
1424
1425```go
1426 // Path routes, folded BEFORE aliases so an alias of a prefix-routed
1427 // hostname inherits its map along with everything else it copies — an
1428 // alias is meant to be indistinguishable from its primary, and a
1429 // hostname that routed by prefix under one name but not the other would
1430 // be a trap.
1431 //
1432 // Targets are recorded as hostnames, not resolved here: every route is
1433 // already in m by this point, but resolving now would freeze a copy of
1434 // the target's entry, and the target's own row may still be rewritten by
1435 // the alias fold below.
1436 paths, err := e.reg.ListPaths()
1437 if err != nil {
1438 return err
1439 }
1440 for _, p := range paths {
1441 up, ok := m[p.Host]
1442 if !ok {
1443 // The hostname has no route on this box. The reconcile pass
1444 // should not have produced this row; nothing to attach it to.
1445 continue
1446 }
1447 if up.paths == nil {
1448 up.paths = make(map[string]string)
1449 }
1450 up.paths[p.Prefix] = p.Target
1451 m[p.Host] = up
1452 }
1453```
1454
1455- [ ] **Step 5: Split the lookup and add the path-aware form**
1456
1457Replace `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`.
1458
1459```go
1460func lookupIn(m map[string]upstream, host string) (upstream, bool) {
1461 if up, ok := m[host]; ok {
1462 return up, true
1463 }
1464 if i := strings.IndexByte(host, '.'); i > 0 {
1465 if up, ok := m["*"+host[i:]]; ok {
1466 return up, true
1467 }
1468 }
1469 return upstream{}, false
1470}
1471
1472// lookupUpstream resolves a hostname alone. Kept as the primitive because not
1473// every caller is answering "where does this REQUEST go" — the routability
1474// check in the ACME/HostPolicy path asks only whether this box serves the
1475// hostname at all, and giving that question a path would make a hostname's
1476// routability depend on which URL happened to ask.
1477func (e *Edge) lookupUpstream(host string) (upstream, bool) {
1478 return lookupIn(e.current(), host)
1479}
1480
1481// lookupUpstreamPath resolves a request: the hostname exactly as
1482// lookupUpstream does, then — only for a hostname carrying a prefix map — the
1483// first path segment.
1484//
1485// Both lookups read ONE snapshot of the map. Calling lookupUpstream and then
1486// current() again would let a reload land between them and resolve a prefix
1487// against a different table than the host came from.
1488//
1489// Every miss falls through to the hostname's own route rather than failing:
1490// an unlisted prefix, a dangling target, a request for "/". A prefix map is a
1491// migration in progress, and the un-migrated answer is always the safe one.
1492func (e *Edge) lookupUpstreamPath(host, path string) (upstream, bool) {
1493 m := e.current()
1494 up, ok := lookupIn(m, host)
1495 if !ok || up.paths == nil {
1496 return up, ok
1497 }
1498 seg := firstSegment(path)
1499 if seg == "" {
1500 return up, true
1501 }
1502 target, ok := up.paths[seg]
1503 if !ok {
1504 return up, true
1505 }
1506 tu, ok := m[target]
1507 if !ok {
1508 return up, true
1509 }
1510 return tu, true
1511}
1512
1513// firstSegment returns the first path segment of an absolute URL path,
1514// without slashes: "/paulca/awesomeconf" -> "paulca". "" for "/", for the
1515// empty string, and for anything not starting with "/".
1516//
1517// Returning the segment rather than testing a string prefix is what makes
1518// matching segment-bounded: "/paulcarson" yields "paulcarson", which is not
1519// equal to "paulca", so a longer account name can never be dragged across a
1520// migration by a shorter one.
1521func firstSegment(p string) string {
1522 if p == "" || p[0] != '/' {
1523 return ""
1524 }
1525 p = p[1:]
1526 if i := strings.IndexByte(p, '/'); i >= 0 {
1527 p = p[:i]
1528 }
1529 return p
1530}
1531```
1532
1533- [ ] **Step 6: Use the path-aware form in `ServeHTTP`**
1534
1535At `internal/edge/edge.go:930`, change:
1536
1537```go
1538 up, ok := e.lookupUpstream(host)
1539```
1540
1541to:
1542
1543```go
1544 up, ok := e.lookupUpstreamPath(host, r.URL.Path)
1545```
1546
1547Leave the call at `edge.go:1455` alone — that is the routability check the doc comment above `lookupUpstream` describes.
1548
1549Do 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.
1550
1551- [ ] **Step 7: Run the tests to verify they pass**
1552
1553Run: `go test ./internal/edge/ -count=1`
1554Expected: PASS, including every existing edge test — especially the alias tests, which prove the alias fold still behaves.
1555
1556- [ ] **Step 8: Verify the whole build is clean**
1557
1558Run: `go build ./... && go vet ./... && gofmt -l . && go test ./... -count=1`
1559Expected: all clean.
1560
1561- [ ] **Step 9: Commit**
1562
1563```bash
1564git add internal/edge/edge.go internal/edge/path_test.go
1565git commit -m "edge: resolve a leading path segment for hosts that carry a prefix map
1566
1567Host resolution is unchanged and stays the primitive: the routability check
1568in the cert path asks only whether this box serves a hostname, and must not
1569gain a path. Request dispatch gets a path-aware wrapper that reads one
1570snapshot of the map for both lookups.
1571
1572Matching is segment-wise equality, so /paulcarson is never dragged across a
1573migration by /paulca, and the prefix is not stripped, so the flipped and
1574unflipped URL spaces stay identical. Every miss — unlisted prefix, dangling
1575target, bare / — falls through to the hostname's own route.
1576
1577Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
1578```
1579
1580---
1581
1582### Task 5: The write door and the read surfaces
1583
1584**Files:**
1585- Modify: `internal/console/apidomains.go` (new handler, alongside the capabilities handler ~line 482)
1586- Modify: `internal/console/authcli.go` (route registration, ~line 624)
1587- Modify: `routes_cli.go` (path rows in `carlos routes`)
1588- Test: `internal/console/apidomainpaths_test.go` (create)
1589
1590**Interfaces:**
1591- Consumes: `domains.SetClaimPaths`, `domains.ErrTooManyPaths`, `domains.ErrBadPathPrefix`, `domains.MaxPathsPerClaim` (Task 2).
1592- Produces: `PUT /api/cli/apps/{account}/{app}/domains/{host}/paths`.
1593
1594- [ ] **Step 1: Write the failing tests**
1595
1596Create `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.
1597
1598Cover, using that harness:
1599
16001. **`TestAPIDomainPathsPublishes`** — `PUT` with body `{"paths":{"paulca":"go.tito.io"}}` returns 200, and `domains.GetClaim` shows the map.
16012. **`TestAPIDomainPathsReplaces`** — a second `PUT` with `{"paths":{"bigcorp":"go.tito.io"}}` leaves `bigcorp` only; `paulca` is gone.
16023. **`TestAPIDomainPathsEmptyBodyClears`** — `{"paths":{}}` clears the map and returns 200.
16034. **`TestAPIDomainPathsRejectsOtherAccount`** — a principal scoped to another account gets the family's byte-identical 404, not a 403.
16045. **`TestAPIDomainPathsRejectsOversize`** — a map above `domains.MaxPathsPerClaim` returns 422.
16056. **`TestAPIDomainPathsRejectsBadPrefix`** — `{"paths":{"/paulca":"go.tito.io"}}` returns 422.
1606
1607Each asserts the status code and, where it applies, reads the claim back to prove the effect.
1608
1609- [ ] **Step 2: Run the tests to verify they fail**
1610
1611Run: `go test ./internal/console/ -run TestAPIDomainPaths -count=1`
1612Expected: FAIL — 404 from an unregistered route.
1613
1614- [ ] **Step 3: Add the handler**
1615
1616In `internal/console/apidomains.go`, following the shape of the capabilities handler:
1617
1618```go
1619// handleAPIDomainPaths answers `PUT /api/cli/apps/{account}/{app}/domains/{host}/paths`:
1620// publish the WHOLE prefix map for a hostname the app already claims.
1621//
1622// Wholesale by contract. The caller regenerates the map from its own database
1623// and publishes the result, so this replaces rather than merges — that is what
1624// makes a republish a full repair and lets a removal express a rollback. There
1625// is deliberately no per-prefix add or delete verb: two ways to write the same
1626// state is two ways for it to drift from the caller's own.
1627func (s *Server) handleAPIDomainPaths(w http.ResponseWriter, r *http.Request) {
1628 ctx := r.Context()
1629 acct, app, ok := s.apiAppScope(w, r) // the family's existing scope helper
1630 if !ok {
1631 return
1632 }
1633 host := r.PathValue("host")
1634
1635 var body struct {
1636 Paths map[string]string `json:"paths"`
1637 }
1638 if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<20)).Decode(&body); err != nil {
1639 jsonError(w, http.StatusBadRequest, "could not parse the request body as JSON")
1640 return
1641 }
1642
1643 changed, err := domains.SetClaimPaths(ctx, s.cfg.Releases, host, acct.Sqid, app, body.Paths)
1644 if errors.Is(err, domains.ErrTooManyPaths) {
1645 jsonError(w, http.StatusUnprocessableEntity,
1646 fmt.Sprintf("a hostname may carry at most %d path prefixes", domains.MaxPathsPerClaim))
1647 return
1648 } else if errors.Is(err, domains.ErrBadPathPrefix) {
1649 jsonError(w, http.StatusUnprocessableEntity,
1650 "each path prefix must be a single bare segment: \"paulca\", not \"/paulca\"")
1651 return
1652 } else if errors.Is(err, domains.ErrHostUnavailable) {
1653 // The family's byte-identical 404 discipline: a caller who does not
1654 // hold this claim learns nothing about whether it exists.
1655 http.NotFound(w, r)
1656 return
1657 } else if err != nil {
1658 log.Printf("console: api domains paths %s/%s %s: %v", acct.Sqid, app, host, err)
1659 jsonError(w, http.StatusBadGateway, "could not update the hostname's path routes — try again")
1660 return
1661 }
1662 writeJSON(w, http.StatusOK, map[string]any{
1663 "host": host,
1664 "paths": body.Paths,
1665 "changed": changed,
1666 })
1667}
1668```
1669
1670Match `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.
1671
1672- [ ] **Step 4: Register the route**
1673
1674In `internal/console/authcli.go`, beside the other domain routes:
1675
1676```go
1677 s.cliRoute("PUT /api/cli/apps/{account}/{app}/domains/{host}/paths", svcrole.Allow(svcrole.Publish), s.handleAPIDomainPaths)
1678```
1679
1680`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.
1681
1682- [ ] **Step 5: Run the tests to verify they pass**
1683
1684Run: `go test ./internal/console/ -count=1`
1685Expected: PASS.
1686
1687- [ ] **Step 6: Show path routes in `carlos routes`**
1688
1689In `routes_cli.go`, after each host's row, print its prefixes indented — read the file's existing table-building code and follow it exactly:
1690
1691```go
1692 // Path routes under their hostname: the flipped set has to be visible
1693 // from the command operators already use, or a migration is invisible
1694 // until something breaks.
1695 for _, p := range pathsFor(rt.Host) {
1696 fmt.Fprintf(tw, " /%s\t\t-> %s\t\t\t\t\n", p.Prefix, p.Target)
1697 }
1698```
1699
1700Source 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).
1701
1702- [ ] **Step 7: Run the full suite**
1703
1704Run: `go build ./... && go vet ./... && gofmt -l . && go test ./... -count=1`
1705Expected: all clean.
1706
1707- [ ] **Step 8: Commit**
1708
1709```bash
1710git add internal/console/apidomains.go internal/console/authcli.go internal/console/apidomainpaths_test.go routes_cli.go
1711git commit -m "console: publish a hostname's prefix map; show path routes in carlos routes
1712
1713PUT .../domains/{host}/paths replaces the whole map, reusing the domains
1714family's auth, account scoping and byte-identical 404 discipline. Publish
1715scope only — deciding where an account's traffic goes is not an
1716instance-scoped decision.
1717
1718No write verb on the CLI: the tenant's platform is the source of truth, and a
1719second writer would drift from it the first time anyone used it.
1720
1721Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
1722```
1723
1724---
1725
1726### Task 6: End-to-end proof
1727
1728**Files:**
1729- Test: `internal/edge/path_e2e_test.go` (create)
1730
1731**Interfaces:**
1732- Consumes: everything from Tasks 1-4.
1733- Produces: nothing. This is the deliverable that says the machinery works.
1734
1735This 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.
1736
1737- [ ] **Step 1: Write the test**
1738
1739Create `internal/edge/path_e2e_test.go`:
1740
1741```go
1742package edge
1743
1744import (
1745 "context"
1746 "net/http"
1747 "net/http/httptest"
1748 "testing"
1749 "time"
1750
1751 "github.com/carlosframework/platform/internal/domains"
1752 "github.com/carlosframework/platform/internal/registry"
1753)
1754
1755// TestPathFlipEndToEnd walks the whole chain a real flip takes — claim
1756// published, reconcile pass, fingerprint moves, edge reloads, traffic changes
1757// — and then walks it backwards. Each earlier task proved one layer against a
1758// hand-written input; this is the only test that proves they compose.
1759//
1760// The registry write and the edge reload are driven directly rather than
1761// waited on: the 2s tick is a latency bound, not a correctness property, and
1762// polling it would make this test slow and flaky. What must be true is that
1763// the fingerprint MOVES, which is asserted rather than assumed.
1764func TestPathFlipEndToEnd(t *testing.T) {
1765 ctx := context.Background()
1766 classic, _ := hostEchoBackend(t, "classic")
1767 golive, _ := hostEchoBackend(t, "go")
1768
1769 reg := openReg(t)
1770 if err := reg.Upsert(registry.Route{
1771 Host: "ti.to", Kind: "service", Account: "tito", App: "classic",
1772 Addr: classic.Listener.Addr().String(), Status: "ready",
1773 }); err != nil {
1774 t.Fatal(err)
1775 }
1776 if err := reg.Upsert(registry.Route{
1777 Host: "go.tito.io", Kind: "service", Account: "tito", App: "go",
1778 Addr: golive.Listener.Addr().String(), Status: "ready",
1779 }); err != nil {
1780 t.Fatal(err)
1781 }
1782
1783 store := newTestStore(t) // the in-memory release.Store the domains tests use
1784 if _, _, err := domains.AttachClaim(ctx, store, domains.Claim{
1785 Schema: 1, Host: "ti.to", Account: "tito", App: "classic",
1786 Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z",
1787 }); err != nil {
1788 t.Fatal(err)
1789 }
1790
1791 e, err := New(reg, nil)
1792 if err != nil {
1793 t.Fatal(err)
1794 }
1795 srv := httptest.NewServer(e)
1796 defer srv.Close()
1797
1798 // Before the flip: everything is classic.
1799 if _, body := getHostPath(t, srv.URL, "ti.to", "/paulca/awesomeconf"); body != "classic" {
1800 t.Fatalf("before the flip: body = %q, want classic", body)
1801 }
1802
1803 fpBefore, err := reg.Fingerprint()
1804 if err != nil {
1805 t.Fatal(err)
1806 }
1807
1808 // The flip: Tito publishes its map, the pass folds it, the edge reloads.
1809 if _, err := domains.SetClaimPaths(ctx, store, "ti.to", "tito", "classic",
1810 map[string]string{"paulca": "go.tito.io"}); err != nil {
1811 t.Fatal(err)
1812 }
1813 if err := domains.ReconcileAliases(ctx, store, reg, time.Now, nil, nil); err != nil {
1814 t.Fatal(err)
1815 }
1816 fpAfter, err := reg.Fingerprint()
1817 if err != nil {
1818 t.Fatal(err)
1819 }
1820 if fpBefore == fpAfter {
1821 t.Fatal("fingerprint did not move across a flip — the edge's watcher would never reload")
1822 }
1823 if err := e.Reload(); err != nil {
1824 t.Fatal(err)
1825 }
1826
1827 // After the flip: that account is on Go, its neighbours are not.
1828 if _, body := getHostPath(t, srv.URL, "ti.to", "/paulca/awesomeconf"); body != "go" {
1829 t.Fatalf("after the flip: body = %q, want go", body)
1830 }
1831 if _, body := getHostPath(t, srv.URL, "ti.to", "/someoneelse/event"); body != "classic" {
1832 t.Fatalf("after the flip: a neighbouring account moved too, body = %q", body)
1833 }
1834 if _, body := getHostPath(t, srv.URL, "ti.to", "/paulcarson"); body != "classic" {
1835 t.Fatalf("after the flip: a longer account name was dragged along, body = %q", body)
1836 }
1837
1838 // The rollback: republish without that account.
1839 if _, err := domains.SetClaimPaths(ctx, store, "ti.to", "tito", "classic", nil); err != nil {
1840 t.Fatal(err)
1841 }
1842 if err := domains.ReconcileAliases(ctx, store, reg, time.Now, nil, nil); err != nil {
1843 t.Fatal(err)
1844 }
1845 if err := e.Reload(); err != nil {
1846 t.Fatal(err)
1847 }
1848 if _, body := getHostPath(t, srv.URL, "ti.to", "/paulca/awesomeconf"); body != "classic" {
1849 t.Fatalf("after the rollback: body = %q, want classic", body)
1850 }
1851 rows, err := reg.ListPaths()
1852 if err != nil {
1853 t.Fatal(err)
1854 }
1855 if len(rows) != 0 {
1856 t.Fatalf("after the rollback: %d path rows remain, want 0", len(rows))
1857 }
1858}
1859```
1860
1861If `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.
1862
1863- [ ] **Step 2: Run it**
1864
1865Run: `go test ./internal/edge/ -run TestPathFlipEndToEnd -count=1 -v`
1866Expected: PASS.
1867
1868- [ ] **Step 3: Run the full suite**
1869
1870Run: `go build ./... && go vet ./... && gofmt -l . && go test ./... -count=1`
1871Expected: all clean.
1872
1873- [ ] **Step 4: Commit**
1874
1875```bash
1876git add internal/edge/path_e2e_test.go
1877git commit -m "test: end-to-end path flip and rollback
1878
1879Walks claim -> reconcile -> fingerprint -> reload -> traffic, then walks it
1880back. With ti.to not moving behind the edge, this is the only thing that
1881proves the four layers compose rather than each working against a
1882hand-written input.
1883
1884Asserts the fingerprint actually moves: a flip that reconciles correctly and
1885never reaches the live map is the failure this feature is most exposed to.
1886
1887Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
1888```
1889
1890- [ ] **Step 5: Open the PR**
1891
1892```bash
1893git push -u origin HEAD
1894```
1895
1896Then 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.
1897
1898Note: 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.
1899
1900---
1901
1902## Self-Review
1903
1904**Spec coverage**
1905
1906| Spec section | Task |
1907|---|---|
1908| §2 prefix selects a route; bare segments | 1 (table), 4 (lookup) |
1909| §3 `Paths` claim field, wholesale publish, cap | 2 |
1910| §4 `route_paths`, pass-owned, tenancy gates, `Slug` untouched | 1, 3 |
1911| §5 `Fingerprint()` section + a test that the digest moves | 1 (unit), 6 (e2e) |
1912| §6 `lookupUpstream(host, path)`, no stripping, segment bounds, three call sites | 4 |
1913| §7 write door, `SetClaimPaths` single writer | 2, 5 |
1914| §8 read-only surfaces | 5 |
1915| §9 exclusions | Nothing implements them, by construction |
1916| §10 rollout stages 1-5 | Tasks 1-6 |
1917| §11 live checks | Carried into the PR body, not implemented |
1918
1919Spec §4's ruling that `Route.Slug` stays untouched is honoured by no task mentioning it.
1920
1921**Type consistency**
1922
1923`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.
1924
1925**Known soft spots, called out rather than papered over**
1926
1927- 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.
1928- Task 6 may hit an import cycle; the fallback package is named in the step.
1929- 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.
1930