carlos / platform Public

domains: minted routes are aliases, not lifecycle hosts — merge-gate fixes

Five fixes from independent review; minted routes are a new kind of row
and every consumer of the route table needed re-reading.

CRITICAL: canary minting was a lie for instance apps — the minted host
copied Socket/Addr from the STABLE route, so the canary URL proxied to
the stable process while the console labeled it with the canary's
version. Canary hosts now mint for static apps only, where the copied
Channel genuinely is the backend; instance apps log the gap as
carlos canary's (per-host canary instance spawning) and mint nothing.

adopt: adoptable() excludes Platform routes — they were staging
artifacts, minting live/<alias> symlinks nothing execs, try-restarting
nonexistent units, and stamping Version onto alias rows.

status: CollectBox skips Platform routes — measured at exactly double
(2/2/2 from three real routes), i.e. the public page would have
overstated the fleet the moment the feature was switched on.

domains: no second apex is ever minted (guard is "already has one",
under any account — minting is additive, so keying on the expected host
would leave two live apexes after an account change).

host: the AppsDomain gate moves ahead of readPublicIP, so an inert
deployment makes no IMDS probe at all — inert means no network too.

README: alias-not-a-host framing plus three honest limits — additive
minting (an app is never re-homed), a hibernating app's platform domain
502s while it sleeps (copying hibernate would spawn a duplicate process
against an empty DBPath, which is worse), canary hosts static-only.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 8c0e48237fae7269fdd691e38669de155ad9123b parent d2d386e
8 files changed, +219 −10
  • README.md +33 −5
  • internal/domains/reconcile.go +30 −0
  • internal/domains/reconcile_test.go +51 −3
  • internal/host/agent.go +10 −0
  • internal/release/adopt.go +12 −2
  • internal/release/adopt_test.go +49 −0
  • internal/status/snapshot.go +9 −0
  • internal/status/status_test.go +25 −0
diff --git a/README.md b/README.md
index f4f05ce5a..b2b065ff3 100644
--- a/README.md
+++ b/README.md
@@ -40,11 +40,39 @@ the deployment operator's built-in account, created by migration; CLI-added
apps live there, and its sqid (`bab`) is identical on every deployment. The
console writes `apps/<app>/account.json` at claim time; the agent's
reconcile pass (riding the 5-minute tick) reads those manifests and mints
-platform routes, minting canary hosts for hostless canary channels and
-removing only routes it minted itself — **custom domains are never touched
-by reconciliation**. DNS needs one wildcard A record (`*.<apps-domain>` →
-edge; RFC 4592 wildcards match multiple labels); certs come per-host from
-the edge's existing ACME machinery.
+platform routes, and removes only routes it minted itself — **custom
+domains are never touched by reconciliation**. DNS needs one wildcard A
+record (`*.<apps-domain>` → edge; RFC 4592 wildcards match multiple
+labels); certs come per-host from the edge's existing ACME machinery.
+
+A platform domain is an **alias**: a second name for the app's canonical
+route, pointing at the same backend. It is not a lifecycle host — release
+adoption and the fleet status counts both skip minted routes, so a
+deployment that turns this feature on does not double-stage artifacts or
+publish an inflated instance count.
+
+**Three honest limits**, none of them papered over:
+
+- **Minting is additive — an app is never re-homed.** Once an app has a
+ platform apex, changing its account does *not* mint a new one or remove
+ the old: one app, one apex, and moving an app between accounts is a
+ deliberate operator migration (delete the old apex route by hand, let the
+ next pass mint the new one), never a silent side effect of editing a
+ manifest.
+- **A hibernating app's platform domain 502s while the app sleeps.** The
+ activator arms on the canonical route only; minted routes deliberately do
+ not copy the `hibernate` flag, because a minted route carrying it would
+ make the activator spawn a *second* process for the same app against an
+ empty `DBPath` — meaningfully worse than a 502. Wake the app through its
+ canonical host; the platform domain serves normally once it's up.
+- **Canary hosts are minted for static apps only.** For a static app the
+ channel genuinely is the backend (the edge resolves the channel pointer
+ to find that release's tar), so a canary host serves exactly the canary.
+ An instance app's minted canary would only be able to alias the app's
+ *stable* process — a URL claiming to serve the canary while proxying
+ stable — so none is minted; spawning a real per-host canary instance is
+ future work (`carlos canary`), and until it exists the pass logs the gap
+ rather than faking it.
**Config surface:**
diff --git a/internal/domains/reconcile.go b/internal/domains/reconcile.go
index 3f3cc8dce..a514e9e3e 100644
--- a/internal/domains/reconcile.go
+++ b/internal/domains/reconcile.go
@@ -106,10 +106,25 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
// --- 2. platform-domain minting ------------------------------------------
byHost := map[string]bool{}
+ // hasApex tracks apps that already own a platform apex, under ANY
+ // account. The guard is deliberately "already has one" rather than
+ // "already has the account-correct one": minting is additive (nothing
+ // here ever removes an apex), so keying on the expected host would mint
+ // a SECOND apex the moment an app's account changed, leaving both live
+ // forever with no rule for which is canonical. One app, one apex; moving
+ // an app between accounts is a deliberate operator migration, not a
+ // silent side effect of a manifest edit.
+ hasApex := map[string]bool{}
for _, rt := range routes {
byHost[rt.Host] = true
+ if rt.Platform && !rt.Canary {
+ hasApex[rt.App] = true
+ }
}
for _, app := range candidateApps(routes) {
+ if hasApex[app] {
+ continue
+ }
acct := appAccount(routes, accountOf, app)
sq, err := accountSqid(reg, acct)
if err != nil {
@@ -132,6 +147,7 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
log.Printf("domains: minted platform domain %s (upstream %s)", host, src.Host)
routes = append(routes, nr)
byHost[host] = true
+ hasApex[app] = true
}
// --- 3. canary hosts: mint for hostless pointers, remove for retired ----
@@ -166,6 +182,20 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
if src == nil {
continue // app has nothing serving yet; the pointer waits
}
+ // Static apps ONLY. A minted route can only copy an upstream that
+ // already exists, and for an instance/service app the only one
+ // available is the app's stable process — so a "canary" host here
+ // would proxy straight to stable while the console labeled it with
+ // the canary's version: a URL that lies about what it serves.
+ // Spawning a real per-host canary instance is the not-yet-built
+ // `carlos canary`'s job, not something reconciliation can fake. For
+ // static apps the copied Channel genuinely IS the backend (the edge
+ // resolves the pointer to find that channel's tar), so those mint
+ // honestly.
+ if src.Kind != "static" {
+ log.Printf("domains: canary channel %s for app %s has no host: minting one needs a per-host canary instance (carlos canary), not an alias to stable — skipping", channel, app)
+ continue
+ }
host := fmt.Sprintf("%s.%s.%s.%s", name, app, sq, cfg.AppsDomain)
nr := mintedRoute(host, app, acct, *src)
nr.Channel = channel
diff --git a/internal/domains/reconcile_test.go b/internal/domains/reconcile_test.go
index 1379d33ae..4276d8625 100644
--- a/internal/domains/reconcile_test.go
+++ b/internal/domains/reconcile_test.go
@@ -171,9 +171,13 @@ func TestReconcileKeepsStaticApexOnRetiredCanaryChannel(t *testing.T) {
mustGet(t, reg, "site.bab.oncarlos.test") // the apex mint survives
}
+// Canary hosts are minted for STATIC apps only, where the copied channel
+// genuinely is the backend (the static handler resolves the pointer to find
+// the tar). See TestReconcileSkipsCanaryMintForInstanceApps for the other
+// half of that rule and why it exists.
func TestReconcileMintsCanaryHost(t *testing.T) {
- s, reg := dirStore(t), regWith(t, registry.Route{Host: "kass.training", Kind: "instance",
- App: "kass", Status: "ready", Addr: "127.0.0.1:9000"})
+ s, reg := dirStore(t), regWith(t, registry.Route{Host: "kass.training", Kind: "static",
+ App: "kass", Status: "ready", Channel: "stable"})
// The pointer's existence is what reconcile reads — content is not its
// business (the edge verifies signatures when serving).
if _, err := s.Put(ctx, "apps/kass/channels/canary/pricing.json", []byte("{}"), ""); err != nil {
@@ -183,7 +187,7 @@ func TestReconcileMintsCanaryHost(t *testing.T) {
t.Fatal(err)
}
got := mustGet(t, reg, "pricing.kass.bab.oncarlos.test")
- if !got.Platform || got.Channel != "canary/pricing" || got.Addr != "127.0.0.1:9000" || !got.Canary {
+ if !got.Platform || got.Channel != "canary/pricing" || got.Kind != "static" || !got.Canary {
t.Errorf("minted canary route = %+v", got)
}
// A followed canary never doubles up: the minted route follows the
@@ -197,6 +201,50 @@ func TestReconcileMintsCanaryHost(t *testing.T) {
}
}
+// An instance app's canary host would be a LIE: a minted route can only
+// copy an existing upstream, and the only upstream available is the app's
+// stable process — so the "canary" URL would proxy straight to stable while
+// the console labeled it with the canary's version. Nothing here spawns a
+// per-host canary instance; that gap belongs to the not-yet-built `carlos
+// canary`. Minting nothing is the honest answer until then.
+func TestReconcileSkipsCanaryMintForInstanceApps(t *testing.T) {
+ s, reg := dirStore(t), regWith(t, registry.Route{Host: "kass.training", Kind: "instance",
+ App: "kass", Status: "ready", Addr: "127.0.0.1:9000"})
+ if _, err := s.Put(ctx, "apps/kass/channels/canary/pricing.json", []byte("{}"), ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil {
+ t.Fatal(err)
+ }
+ if rt, err := reg.Get("pricing.kass.bab.oncarlos.test"); err != nil || rt != nil {
+ t.Errorf("minted a canary host for an instance app: %+v %v — it would serve stable, not the canary", rt, err)
+ }
+ // The app's ordinary apex still mints: only the canary host is withheld.
+ mustGet(t, reg, "kass.bab.oncarlos.test")
+}
+
+// An account change (or any hand-edited account column) must never leave an
+// app with two live apex domains: minting is additive — the pass never
+// removes an apex — so the guard is "already has one", not "has the
+// account-correct one".
+func TestReconcileNeverMintsASecondApex(t *testing.T) {
+ s, reg := dirStore(t), regWith(t,
+ registry.Route{Host: "kass.training", Kind: "instance", App: "kass",
+ Status: "ready", Addr: "127.0.0.1:9000"},
+ registry.Route{Host: "kass.bab.oncarlos.test", Kind: "instance", App: "kass",
+ Status: "ready", Addr: "127.0.0.1:9000", Platform: true, Account: "ops"},
+ )
+ // The app moves to paul's account (id 2, sqid "bac").
+ manifest(t, s, "kass", "paul", "paul@keymail.dev")
+ if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil {
+ t.Fatal(err)
+ }
+ if rt, err := reg.Get("kass.bac.oncarlos.test"); err != nil || rt != nil {
+ t.Errorf("minted a second apex %+v %v — one app, one platform apex", rt, err)
+ }
+ mustGet(t, reg, "kass.bab.oncarlos.test") // the existing apex stays live
+}
+
func TestReconcileRemovesRetiredCanaryHostOnly(t *testing.T) {
// The canary/old pointer is GONE from the bucket; its platform-minted
// follower must be removed, but the custom route following the same
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 3a41e9dbb..0277c1d7a 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -317,6 +317,16 @@ func readPublicIP() string {
// shape as its siblings.
func domainsLoop(reg *registry.Registry, acmeCacheFlag string) {
ctx := context.Background()
+ // The inert gate is checked BEFORE readPublicIP: an unconfigured
+ // deployment must make no IMDS call at all (a 2s link-local probe on
+ // every agent start, on boxes that aren't even on EC2, for a feature
+ // that is switched off). Inert means inert — no network, not just no
+ // writes. domainsTickConfig re-checks the same variable; that's the
+ // authoritative gate, this is the "don't pay for it first" one.
+ if strings.TrimSpace(os.Getenv("PLATFORM_APPS_DOMAIN")) == "" {
+ log.Printf("agent: domains loop disabled: PLATFORM_APPS_DOMAIN is not set")
+ return
+ }
cfg := domainsTickConfig(os.Getenv, readPublicIP())
if cfg.AppsDomain == "" {
log.Printf("agent: domains loop disabled: PLATFORM_APPS_DOMAIN is not set")
diff --git a/internal/release/adopt.go b/internal/release/adopt.go
index 261879ec4..87d0baf5c 100644
--- a/internal/release/adopt.go
+++ b/internal/release/adopt.go
@@ -116,9 +116,19 @@ func Pass(ctx context.Context, reg *registry.Registry, s Store, pub ed25519.Publ
// adoptable filters the routes a Pass moves: lifecycle-managed instances of
// a known app that are ready (an app-less or mid-provision route has nothing
-// to adopt or must not be disturbed).
+// to adopt or must not be disturbed), excluding platform-minted routes.
+//
+// A platform domain (internal/domains) is an ALIAS for an app's canonical
+// route — a second name pointing at the same backend — not a host with a
+// lifecycle of its own. Adopting one would stage a redundant copy of the
+// artifacts, mint a live/<alias> symlink nothing ever execs, try-restart a
+// carlos-app@<alias> unit that does not exist, and stamp Version onto a row
+// the console then renders as though it were a real instance. The canonical
+// route it aliases is adopted normally — that IS the deploy, and the alias
+// serves the new version the moment it lands.
func adoptable(rt registry.Route) bool {
- return rt.Kind == "instance" && rt.App != "" && (rt.Status == "" || rt.Status == "ready")
+ return rt.Kind == "instance" && rt.App != "" && !rt.Platform &&
+ (rt.Status == "" || rt.Status == "ready")
}
// routeChannel is the channel a route follows; "" means stable.
diff --git a/internal/release/adopt_test.go b/internal/release/adopt_test.go
index 4f10b9019..ad98a5847 100644
--- a/internal/release/adopt_test.go
+++ b/internal/release/adopt_test.go
@@ -170,6 +170,55 @@ func TestPassSkipsUnadoptableRoutes(t *testing.T) {
}
}
+// TestPassSkipsPlatformMintedRoutes: a platform domain is an ALIAS for an
+// app's canonical route, not a lifecycle host of its own. Adopting one would
+// stage a second copy of the artifacts, mint a live/<alias> symlink no unit
+// ever execs, try-restart a carlos-app@<alias> unit that doesn't exist, and
+// stamp Version onto a row the console then renders as if it were a real
+// instance. The canonical route it aliases is adopted normally — that IS the
+// deploy.
+func TestPassSkipsPlatformMintedRoutes(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt := t.TempDir()
+ reg := testRegistry(t)
+
+ shipRelease(t, s, "myapp", "v1", adoptPlatform, []byte("bin"))
+ if err := Promote(ctx, s, priv, "myapp", "canary/x", "v1"); err != nil {
+ t.Fatalf("Promote: %v", err)
+ }
+ // The canonical route and its platform alias, identical but for the flag.
+ addRoute(t, reg, "myapp.example.com", "myapp", "canary/x", false)
+ if err := reg.Upsert(registry.Route{Host: "myapp.bab.oncarlos.test", Kind: "instance",
+ App: "myapp", Channel: "canary/x", Status: "ready", Platform: true}); err != nil {
+ t.Fatal(err)
+ }
+ var restarted []string
+ if err := Pass(ctx, reg, s, pub, opt, func(h string) error {
+ restarted = append(restarted, h)
+ return nil
+ }); err != nil {
+ t.Fatalf("Pass: %v", err)
+ }
+ if v, _ := LiveVersion(opt, "myapp.bab.oncarlos.test"); v != "" {
+ t.Errorf("platform alias was adopted (live = %q) — aliases are not lifecycle hosts", v)
+ }
+ for _, h := range restarted {
+ if h == "myapp.bab.oncarlos.test" {
+ t.Error("adopt try-restarted a platform alias's nonexistent unit")
+ }
+ }
+ if rt, _ := reg.Get("myapp.bab.oncarlos.test"); rt != nil && rt.Version != "" {
+ t.Errorf("adopt stamped Version %q onto a platform alias row", rt.Version)
+ }
+ // The canonical route still adopts: the alias must not have stolen its
+ // pass (canaries bypass bake, so this lands immediately).
+ if v, _ := LiveVersion(opt, "myapp.example.com"); v != "v1" {
+ t.Errorf("canonical route live = %q, want v1 — skipping aliases must not skip real hosts", v)
+ }
+}
+
func TestPassMissingPlatformArtifactSkips(t *testing.T) {
s := NewDirStore(t.TempDir())
ctx := context.Background()
diff --git a/internal/status/snapshot.go b/internal/status/snapshot.go
index acc6242f4..8a05fd2ec 100644
--- a/internal/status/snapshot.go
+++ b/internal/status/snapshot.go
@@ -158,6 +158,15 @@ func CollectBox(routes []registry.Route, p Probes, now time.Time) Box {
awake, asleep := 0, 0
for _, r := range routes {
+ // Platform-minted routes (internal/domains) are additional NAMES for
+ // routes already counted below, not additional instances — counting
+ // both would roughly double every fleet-wide number the moment a
+ // deployment sets PLATFORM_APPS_DOMAIN, publishing a fleet larger
+ // than the one that exists. Same reasoning as the opt-in and
+ // null-propagation rules: the aggregate never overstates.
+ if r.Platform {
+ continue
+ }
switch {
case r.Kind == "static":
counts.Static++
diff --git a/internal/status/status_test.go b/internal/status/status_test.go
index 8796f8b5e..ad2fb4b8a 100644
--- a/internal/status/status_test.go
+++ b/internal/status/status_test.go
@@ -60,6 +60,31 @@ func TestAwakeAsleepNullWhenUnreadable(t *testing.T) {
}
}
+// TestCollectBoxSkipsPlatformMintedRoutes: a platform domain is a second
+// NAME for a route already counted, not a second instance. Counting both
+// would roughly double every fleet-wide instance number the moment
+// PLATFORM_APPS_DOMAIN is set — the public status page overstating the
+// fleet, which is exactly the kind of quiet dishonesty the page's whole
+// design exists to prevent.
+func TestCollectBoxSkipsPlatformMintedRoutes(t *testing.T) {
+ listening := func(string) *bool { v := true; return &v }
+ routes := []registry.Route{
+ {Host: "kass.training", Kind: "instance", Status: "ready"},
+ {Host: "kass.bab.oncarlos.test", Kind: "instance", Status: "ready", Platform: true},
+ {Host: "site.custom", Kind: "static"},
+ {Host: "site.bab.oncarlos.test", Kind: "static", Platform: true},
+ {Host: "sleepy.custom", Kind: "instance", Hibernate: true, Socket: "/s.sock"},
+ {Host: "sleepy.bab.oncarlos.test", Kind: "instance", Hibernate: true, Socket: "/s.sock", Platform: true},
+ }
+ b := status.CollectBox(routes, status.Probes{MachineID: "m", Listening: listening}, time.Now())
+ if b.Routes.Resident != 1 || b.Routes.Static != 1 || b.Routes.Hibernating != 1 {
+ t.Errorf("counts = %+v, want exactly one of each: aliases must not inflate the fleet", b.Routes)
+ }
+ if b.Routes.Awake == nil || *b.Routes.Awake != 1 {
+ t.Errorf("awake = %v, want 1 — the alias must not be counted awake twice", b.Routes.Awake)
+ }
+}
+
func TestLabelStableAndOpaque(t *testing.T) {
a, b := status.Label("machine-1"), status.Label("machine-1")
if a != b || a == "" || strings.Contains(a, "machine") {