carlos / platform Public

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.

Adding a map field makes Claim non-comparable with ==, so the two existing
tests that compared *Claim with != now use reflect.DeepEqual instead.
SetClaimPaths also validates input (cap, prefix shape, target hostname) only
after confirming the claim exists and is owned by the caller, so a malformed
map against a claim the caller doesn't hold still surfaces the neutral
ErrHostUnavailable rather than a validation error.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 8a8e59854ccac6af5fa1db001ac2b2bf1a2649f1 parent 16e43f8
4 files changed, +360 −3
  • internal/domains/claimcache_test.go +2 −1
  • internal/domains/claims.go +143 −0
  • internal/domains/claims_test.go +3 −2
  • internal/domains/paths_test.go +212 −0
diff --git a/internal/domains/claimcache_test.go b/internal/domains/claimcache_test.go
index 985dffad1..6b74485cb 100644
--- a/internal/domains/claimcache_test.go
+++ b/internal/domains/claimcache_test.go
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"os"
+ "reflect"
"testing"
"time"
@@ -117,7 +118,7 @@ func TestClaimCacheAgreesWithTheUncachedFold(t *testing.T) {
t.Errorf("cached fold invented %s", got.Host)
continue
}
- if got != want {
+ if !reflect.DeepEqual(got, want) {
t.Errorf("%s: cached %+v, uncached %+v", got.Host, got, want)
}
}
diff --git a/internal/domains/claims.go b/internal/domains/claims.go
index ec8c8533e..402eb99c4 100644
--- a/internal/domains/claims.go
+++ b/internal/domains/claims.go
@@ -55,6 +55,22 @@ type Claim struct {
// the same change that first writes it.
Wildcard bool `json:"wildcard,omitempty"`
Catchall bool `json:"catchall,omitempty"`
+ // 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"`
}
// ErrHostUnavailable is returned by AttachClaim when host is already claimed
@@ -316,3 +332,130 @@ func SetClaimCapabilities(ctx context.Context, s release.Store, host, account, a
}
}
}
+
+// 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) {
+ 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
+ }
+ // Input validation happens only after existence and ownership are
+ // confirmed: a malformed map submitted against a host the caller
+ // doesn't hold must still surface the neutral ErrHostUnavailable,
+ // never a validation error that would tell a non-owner their input
+ // was even looked at.
+ 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)
+ }
+ }
+ 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
+}
diff --git a/internal/domains/claims_test.go b/internal/domains/claims_test.go
index 72513ef18..0a3fd8063 100644
--- a/internal/domains/claims_test.go
+++ b/internal/domains/claims_test.go
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"os"
+ "reflect"
"strings"
"testing"
@@ -53,7 +54,7 @@ func TestAttachClaimIdempotent(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if *before != *after {
+ if !reflect.DeepEqual(before, after) {
t.Fatalf("object changed on idempotent re-attach: before=%+v after=%+v", before, after)
}
}
@@ -117,7 +118,7 @@ func TestAttachClaimOtherOwnerRefused(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if *before != *after {
+ if !reflect.DeepEqual(before, after) {
t.Fatalf("object changed on refused attach: before=%+v after=%+v", before, after)
}
}
diff --git a/internal/domains/paths_test.go b/internal/domains/paths_test.go
new file mode 100644
index 000000000..54e46ce9b
--- /dev/null
+++ b/internal/domains/paths_test.go
@@ -0,0 +1,212 @@
+package domains
+
+import (
+ "context"
+ "errors"
+ "strconv"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// TestSetClaimPathsRoundTrip: the map written is the map read back.
+func TestSetClaimPathsRoundTrip(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ 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 := release.NewDirStore(t.TempDir())
+ 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 := release.NewDirStore(t.TempDir())
+ 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 := release.NewDirStore(t.TempDir())
+ 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 := release.NewDirStore(t.TempDir())
+ 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 := release.NewDirStore(t.TempDir())
+ 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 := release.NewDirStore(t.TempDir())
+ 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)
+ }
+ }
+}
+
+// TestAttachConvergePreservesPaths: a repoint must not strip the prefix map.
+func TestAttachConvergePreservesPaths(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ 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)
+ }
+}