carlos / platform Public

domains: name the refusal that made a cross-box flip a silent no-op

ReconcileAliases resolves a prefix's target through byHost — this box's
routes. Backhaul fires only on a HOST miss, never for a path target, so a
target homed on another box derives no route_paths row on any box. `ti.to`
on one box and `go.tito.io` on another is the likely shape for two apps as
different as Tito classic and Tito Go, and it was also the ONLY refusal in
the path block that logged nothing: publish, reconcile, and route nothing,
forever, with no signal. The feature has no production consumer for months,
which is exactly how long that silence would last.

We are not making cross-box path targets work — that needs a backhaul
equivalent for path targets and is a design change. This fixes the silence:

- One aggregated line per host per pass, naming the count and the first few
  unresolved targets. Aggregated because this runs every ~2s and a 10k-entry
  map must not write 10k lines every two seconds.
- The comment names three distinct causes with three different remedies:
  the target does not exist, the target is homed elsewhere, the target is
  local but not ready. Conflating the first two sends an operator hunting
  for a deleted route.
- Targets are gated on Status == "ready", which spec §4 already required and
  the code did not do. The edge filtered unready routes out of its map
  anyway, so traffic fell through safely — but `carlos routes` would print
  the path row as if it were serving, and that is the one surface built for
  diagnosing a flip.

Tenancy is still checked first, so a cross-tenant target is named as
cross-tenant rather than swept into the aggregate.

TestReconcileCapsPathRows now asserts WHICH prefixes survive the cap, not
just how many: the sort is what makes every box derive the identical subset,
and deleting it left the old assertion green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 2c773b4846155b7f1f48c65b190606bfa73be208 parent 00dada1
2 files changed, +154 −9
  • internal/domains/aliases.go +59 −8
  • internal/domains/aliases_paths_test.go +95 −1
diff --git a/internal/domains/aliases.go b/internal/domains/aliases.go
index 6a6776d6b..e4d315418 100644
--- a/internal/domains/aliases.go
+++ b/internal/domains/aliases.go
@@ -13,7 +13,9 @@ package domains
import (
"context"
"log"
+ "slices"
"sort"
+ "strings"
"time"
"github.com/carlosframework/platform/internal/registry"
@@ -123,6 +125,14 @@ func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Regist
// for the same reason: the prefix map belongs to that route, so a box
// that does not serve the route derives nothing.
//
+ // The TARGET is resolved through the same local byHost, which makes
+ // co-location a hard constraint of the feature: a prefix's target
+ // route must be homed on the same box as the hostname's own route.
+ // There is no backhaul equivalent for a path target — backhaul fires
+ // on a host miss, and the host resolves fine here — so a target on
+ // another box derives no row anywhere at all. See the unresolved log
+ // below, and design spec §6.
+ //
// Ordered BEFORE the wildcard section on purpose: Paths is a third
// claim capability independent of Wildcard (claims.go's comment on
// the field), and the wildcard section below `continue`s for every
@@ -150,6 +160,14 @@ func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Regist
c.Host, len(prefixes), MaxPathsPerClaim, len(prefixes)-MaxPathsPerClaim)
prefixes = prefixes[:MaxPathsPerClaim]
}
+ // Unresolved targets are counted and named ONCE per host per
+ // pass, not logged per prefix: this pass runs every ~2s on
+ // every box, and a 10k-entry map whose targets are all
+ // elsewhere would otherwise write 10k lines every two
+ // seconds. The count is the operator's signal; the first few
+ // names are enough to identify which map is wrong.
+ unresolved := 0
+ var unresolvedEg []string
for _, prefix := range prefixes {
target := c.Paths[prefix]
if !validPathPrefix(prefix) {
@@ -157,27 +175,60 @@ func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Regist
continue
}
tgtRt, ok := byHost[target]
- if !ok {
- // Target is not a route on this box. No row, so the
- // prefix falls through to the host's own route — the
- // right failure direction for a migration: a missing
- // destination serves the ORIGIN, never a 404.
- continue
- }
// The authorization rule this feature adds: a prefix must
// never point another tenant's traffic at your backend.
// Account only — an account routing between its own apps
// (classic and Go ARE two apps) is the whole use case.
- if release.RouteAccount(tgtRt) != c.Account {
+ // Checked before readiness so a cross-tenant target is
+ // named as cross-tenant rather than swept into the
+ // unresolved count below.
+ if ok && release.RouteAccount(tgtRt) != c.Account {
log.Printf("domains: path %s/%s refused: target %s belongs to %s, claim says %s",
c.Host, prefix, target, release.RouteAccount(tgtRt), c.Account)
continue
}
+ if !ok || tgtRt.Status != "ready" {
+ // The target is not a local READY route, which has
+ // three distinct causes with three different
+ // remedies, and the log below cannot tell them apart
+ // from here:
+ //
+ // 1. The target route does not exist anywhere — a
+ // typo in the published map, or a route deleted
+ // out from under it. Remedy: fix the map, or
+ // re-create the route.
+ // 2. The target exists but is HOMED ON ANOTHER BOX.
+ // byHost holds this box's routes only, and there
+ // is no backhaul equivalent for a path target
+ // (backhaul fires on a HOST miss), so nothing
+ // derives the row anywhere. Remedy: co-locate —
+ // a prefix's target must be homed on the same box
+ // as the hostname's own route (design spec §6).
+ // 3. The target is local but not ready yet — a
+ // route mid-provision. Self-resolving; the row
+ // appears on the pass after it goes ready.
+ //
+ // No row either way, so the prefix falls through to
+ // the host's own route — the right failure direction
+ // for a migration: a missing destination serves the
+ // ORIGIN, never a 404. Silent traffic is the danger
+ // here, not broken traffic, which is why this is
+ // logged at all.
+ unresolved++
+ if len(unresolvedEg) < 3 && !slices.Contains(unresolvedEg, target) {
+ unresolvedEg = append(unresolvedEg, target)
+ }
+ continue
+ }
desiredPaths[c.Host+"\x00"+prefix] = registry.RoutePath{
Host: c.Host, Prefix: prefix, Target: target,
Account: c.Account, App: c.App,
}
}
+ if unresolved > 0 {
+ log.Printf("domains: paths on %s: %d prefix(es) name no local ready route (e.g. %s) — the target is missing, or it is homed on another box; a path target must be homed on the SAME box as %s. Those prefixes serve %s's own route.",
+ c.Host, unresolved, strings.Join(unresolvedEg, ", "), c.Host, c.Host)
+ }
}
}
diff --git a/internal/domains/aliases_paths_test.go b/internal/domains/aliases_paths_test.go
index 802cee431..db02d65cc 100644
--- a/internal/domains/aliases_paths_test.go
+++ b/internal/domains/aliases_paths_test.go
@@ -1,8 +1,12 @@
package domains
import (
+ "bytes"
"context"
+ "log"
+ "sort"
"strconv"
+ "strings"
"testing"
"github.com/carlosframework/platform/internal/registry"
@@ -169,7 +173,97 @@ func TestReconcileCapsPathRows(t *testing.T) {
if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil {
t.Fatal(err)
}
- if got := pathsByPrefix(t, reg, "ti.to"); len(got) != MaxPathsPerClaim {
+ got := pathsByPrefix(t, reg, "ti.to")
+ if len(got) != MaxPathsPerClaim {
t.Fatalf("derived %d rows, want the cap of %d", len(got), MaxPathsPerClaim)
}
+ // WHICH prefixes survive, not just how many. The count alone passes with
+ // the sort deleted, and the sort is the safety-critical half: every box
+ // folds the same claim independently, so a truncation that varied with
+ // Go's map iteration order would let the same URL resolve to different
+ // backends depending on which edge answered.
+ want := make([]string, 0, len(big))
+ for p := range big {
+ want = append(want, p)
+ }
+ sort.Strings(want)
+ want = want[:MaxPathsPerClaim]
+ for _, p := range want {
+ if _, ok := got[p]; !ok {
+ t.Fatalf("prefix %q was dropped; the surviving set must be the first %d in SORTED order, identically on every box", p, MaxPathsPerClaim)
+ }
+ }
+}
+
+// TestReconcileSkipsUnreadyTarget: a target route that exists on this box but
+// is not ready yet contributes no row (design spec §4, "a target that is not
+// a local READY route contributes no row"). The edge filters unready routes
+// out of its map anyway, so traffic already fell through safely — what this
+// gate buys is that `carlos routes` and the edge agree about which prefixes
+// are actually serving, and that surface is the one built for diagnosing a
+// flip.
+func TestReconcileSkipsUnreadyTarget(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ reg := aliasRegWith(t)
+ addRoute(t, reg, "ti.to", "tito", "classic")
+ if err := reg.Upsert(registry.Route{
+ Host: "go.tito.io", App: "classic", Account: "tito", Kind: "instance", Status: "provisioning",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ putClaim(t, s, Claim{
+ Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to",
+ CreatedAt: "2026-09-03T00:00:00Z",
+ Paths: map[string]string{"paulca": "go.tito.io"},
+ })
+
+ if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil {
+ t.Fatal(err)
+ }
+ if got := pathsByPrefix(t, reg, "ti.to"); len(got) != 0 {
+ t.Fatalf("route_paths = %v, want no row for a target that is not ready", got)
+ }
+}
+
+// TestReconcileLogsUnresolvedTargetsOncePerHost is the critical-silence fix.
+// A target homed on ANOTHER BOX is the likeliest real-world refusal in this
+// feature — `ti.to` and `go.tito.io` are two very different apps — and it
+// used to be the ONLY refusal in the path block that said nothing at all, so
+// a migration could be published, accepted, and never route, forever, with
+// no signal. It must log; and because this pass runs every ~2s against a map
+// that may hold 10k entries, it must log ONE line per host per pass.
+func TestReconcileLogsUnresolvedTargetsOncePerHost(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ reg := aliasRegWith(t)
+ addRoute(t, reg, "ti.to", "tito", "classic")
+ putClaim(t, s, Claim{
+ Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to",
+ CreatedAt: "2026-09-03T00:00:00Z",
+ Paths: map[string]string{
+ "paulca": "elsewhere.example",
+ "bigcorp": "elsewhere.example",
+ "acme": "also-elsewhere.example",
+ },
+ })
+
+ var buf bytes.Buffer
+ old := log.Writer()
+ log.SetOutput(&buf)
+ defer log.SetOutput(old)
+ if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil {
+ t.Fatal(err)
+ }
+ log.SetOutput(old)
+
+ out := buf.String()
+ if n := strings.Count(out, "name no local ready route"); n != 1 {
+ t.Fatalf("logged the unresolved-target line %d times, want exactly one per host per pass:\n%s", n, out)
+ }
+ for _, want := range []string{"ti.to", "3 prefix(es)", "elsewhere.example", "homed on another box"} {
+ if !strings.Contains(out, want) {
+ t.Fatalf("unresolved-target log is missing %q:\n%s", want, out)
+ }
+ }
}