carlos / platform Public

deploy/domains: trust the door's resolved route, complete the CLI's job

Final-review fixes (whole-branch review, task 9):

- deploy.go's attachUnclaimedHost printed first-route wording
  unconditionally instead of reading domainAttach's own resolvedRoute
  answer, so a door-side alias (spec #1's yes/no split lives door-side)
  would still claim to have minted a first route. Now branches on
  resolvedRoute == host (first route) vs != host (alias, "aliases <route>",
  no channel named).
- carlos domains attach/list now complete the job per the no-infra rule
  (spec S5): attach re-fetches the domain list and prints the attached
  host's DNS stamp and guidance; list gains a GUIDANCE column.
- Spec S4 corrected to describe the single A-record guidance path actually
  built (domainGuidance/EdgeIPs, apex or not), naming the CNAME split as
  future polish rather than a built distinction.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell f073387299cbaad92dc2f59df3bab3799a3ecb25 parent 3ec44ec
5 files changed, +197 −16
  • deploy.go +18 −2
  • deploy_test.go +78 −2
  • docs/superpowers/specs/2026-08-12-domain-attach-design.md +8 −1
  • domains_cli.go +46 −4
  • domains_cli_test.go +47 −7
diff --git a/deploy.go b/deploy.go
index 111acdb81..ce785ce8a 100644
--- a/deploy.go
+++ b/deploy.go
@@ -420,10 +420,26 @@ func attachUnclaimedHost(ctx context.Context, sess *apiSession, acct apiAcct, ap
if kind == "static" {
attachKind = kind
}
- if _, _, err := sess.domainAttach(ctx, acct, app, host, "", attachKind, channel); err != nil {
+ // The door, not this call's own request, decides alias-vs-first-route
+ // (spec §4's "one deliberate asymmetry"): resolvedRoute is the host
+ // itself on a genuine first-route attach, or a DIFFERENT route when the
+ // app already had one ready and the door aliased onto it instead —
+ // which can happen even though attachKind asked for "static", since a
+ // ready route always wins over minting a second one (§1's yes/no
+ // split lives door-side). Printing attachKind/channel unconditionally
+ // here would claim a first route was minted when the door actually
+ // aliased, so the two cases get two different lines, and the alias one
+ // never names a channel — an alias serves the primary's channel, not
+ // one of its own.
+ _, resolvedRoute, err := sess.domainAttach(ctx, acct, app, host, "", attachKind, channel)
+ if err != nil {
log.Fatalf("deploy: attach %s: %v", host, err)
}
- fmt.Fprintf(stdout, "%s %s (first route: %s, channel %s)\n", colorFor(stdout).ok("attached"), host, attachKind, channel)
+ if resolvedRoute == host {
+ fmt.Fprintf(stdout, "%s %s (first route: %s, channel %s)\n", colorFor(stdout).ok("attached"), host, attachKind, channel)
+ } else {
+ fmt.Fprintf(stdout, "%s %s (aliases %s)\n", colorFor(stdout).ok("attached"), host, resolvedRoute)
+ }
}
// hostHasInstance reports whether host is already answered by one of the
diff --git a/deploy_test.go b/deploy_test.go
index 500092546..b9a3cd006 100644
--- a/deploy_test.go
+++ b/deploy_test.go
@@ -615,6 +615,12 @@ type deployFakeConsole struct {
domainsListStatus int // 0 = normal 200 JSON list; else this status with a plain (non-JSON) body — simulates an old console (404) or an unwell one (5xx)
attachStatus int // 0 = normal 201; else this status with an {"error": attachErrMsg} body
attachErrMsg string
+ // attachRouteOverride, when non-empty, is the "route" the POST /domains
+ // response names instead of echoing the attached host — simulating the
+ // door aliasing this host onto an existing route rather than minting it
+ // as a first route, the review#1 case attachUnclaimedHost must read off
+ // the door's own answer rather than assume.
+ attachRouteOverride string
}
func newDeployFakeConsole(t *testing.T, shipVersion string) *deployFakeConsole {
@@ -650,7 +656,7 @@ func newDeployFakeConsole(t *testing.T, shipVersion string) *deployFakeConsole {
mux.HandleFunc("POST /api/cli/apps/{account}/{app}/domains", func(w http.ResponseWriter, r *http.Request) {
fc.record(r)
fc.mu.Lock()
- status, errMsg := fc.attachStatus, fc.attachErrMsg
+ status, errMsg, routeOverride := fc.attachStatus, fc.attachErrMsg, fc.attachRouteOverride
fc.mu.Unlock()
if status != 0 {
w.WriteHeader(status)
@@ -661,7 +667,11 @@ func newDeployFakeConsole(t *testing.T, shipVersion string) *deployFakeConsole {
Host string `json:"host"`
}
json.NewDecoder(r.Body).Decode(&in)
- json.NewEncoder(w).Encode(map[string]any{"host": in.Host, "route": in.Host, "created": true})
+ route := in.Host
+ if routeOverride != "" {
+ route = routeOverride
+ }
+ json.NewEncoder(w).Encode(map[string]any{"host": in.Host, "route": route, "created": true})
})
mux.HandleFunc("POST /api/cli/apps/{account}/{app}/releases", func(w http.ResponseWriter, r *http.Request) {
fc.record(r)
@@ -708,6 +718,12 @@ func (fc *deployFakeConsole) setAttachRefusal(status int, msg string) {
fc.attachStatus, fc.attachErrMsg = status, msg
}
+func (fc *deployFakeConsole) setAttachRouteOverride(route string) {
+ fc.mu.Lock()
+ defer fc.mu.Unlock()
+ fc.attachRouteOverride = route
+}
+
func (fc *deployFakeConsole) record(r *http.Request) {
fc.mu.Lock()
defer fc.mu.Unlock()
@@ -816,6 +832,66 @@ func TestDeployAutoAttachesUnclaimedStaticHost(t *testing.T) {
checkNoRegistry()
}
+// TestDeployAutoAttachPrintsAliasWordingOnDifferingRoute is final-review
+// finding #1: attachUnclaimedHost must print off the door's OWN answer, not
+// assume "first route" just because that's what it asked for. A console
+// that resolves this deploy's -host to a DIFFERENT route than the host
+// itself — the alias case, e.g. because the app already had a ready route
+// by the time the door decided (spec §1's yes/no split lives door-side,
+// not in what this call sent) — must get the "aliases <route>" line with
+// no channel named; a channel belongs to the primary route, and an alias
+// serves whatever channel that primary already follows.
+func TestDeployAutoAttachPrintsAliasWordingOnDifferingRoute(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ checkNoRegistry := requireNoRegistryTouch(t)
+
+ const (
+ app = "sitely"
+ host = "alias.example.test"
+ primary = "sitely.canary.oncarlos.test"
+ channel = "canary/site"
+ )
+ fc := newDeployFakeConsole(t, "sha0001")
+ fc.setAttachRouteOverride(primary)
+ credFile, err := loadCredentials()
+ if err != nil {
+ t.Fatal(err)
+ }
+ credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token",
+ ExpiresAt: time.Now().Add(time.Hour)})
+ if err := credFile.save(); err != nil {
+ t.Fatal(err)
+ }
+
+ edgeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("X-Carlos-Version", "sha0001")
+ }))
+ defer edgeSrv.Close()
+ origOrigin := watchOrigin
+ watchOrigin = func(h string) string { return edgeSrv.URL }
+ defer func() { watchOrigin = origOrigin }()
+
+ siteDir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v1"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ buf := captureStdout(t)
+ runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0001",
+ "-channel", channel, "-host", host, "-timeout", "5s", siteDir})
+ out := buf.String()
+
+ if want := "attached " + host + " (aliases " + primary + ")"; !strings.Contains(out, want) {
+ t.Fatalf("stdout = %q, want the alias wording naming the door's resolved route %q", out, primary)
+ }
+ if strings.Contains(out, "first route") || strings.Contains(out, "channel "+channel) {
+ t.Fatalf("stdout wrongly named a channel for an alias attach (a channel belongs to the primary):\n%s", out)
+ }
+
+ checkNoRegistry()
+}
+
// TestDeployExitsHonestlyWhenDNSIsNotPointingYet pins the exit contract for
// a connection-class watch timeout: the deploy itself already succeeded
// (shipped, promoted) but the host never answered at all — the shape a
diff --git a/docs/superpowers/specs/2026-08-12-domain-attach-design.md b/docs/superpowers/specs/2026-08-12-domain-attach-design.md
index 0860a218e..c5d736499 100644
--- a/docs/superpowers/specs/2026-08-12-domain-attach-design.md
+++ b/docs/superpowers/specs/2026-08-12-domain-attach-design.md
@@ -171,7 +171,14 @@ it:
The GET carries the DNS stamp per host (`pointing` / `elsewhere`, the
existing Domains-table machinery pointed at attached hosts) plus what to
-create: A/AAAA targets for an apex, CNAME target otherwise.
+create: an A-record instruction naming this deployment's edge IPs
+(`domainGuidance`, `internal/console/view.go`), the same wording whether the
+host is an apex or not. The CNAME-for-non-apex split named in an earlier
+draft of this line was never built — the console has one guidance path, not
+two, and every surface (Settings, `carlos domains`, `carlos deploy`'s DNS
+exit) reads it verbatim, so they can never disagree. A CNAME option for
+non-apex hosts is future polish, not a correctness gap: an A record at the
+edge IPs works for any host shape.
## 5. `carlos deploy -host` completes the story
diff --git a/domains_cli.go b/domains_cli.go
index 8cbd8fba9..70796ea77 100644
--- a/domains_cli.go
+++ b/domains_cli.go
@@ -81,8 +81,9 @@ func runDomainsAttach(args []string) {
}
sess := requireAPISession("attach", *consoleFlag)
printUsing(stdout, sess)
- created, resolvedRoute, err := sess.domainAttach(context.Background(), apiAccount(acctRaw, acctIDRaw),
- *app, host, strings.TrimSpace(*route), "", "")
+ acct := apiAccount(acctRaw, acctIDRaw)
+ ctx := context.Background()
+ created, resolvedRoute, err := sess.domainAttach(ctx, acct, *app, host, strings.TrimSpace(*route), "", "")
if err != nil {
log.Fatalf("domains attach: %v", err)
}
@@ -91,6 +92,43 @@ func runDomainsAttach(args []string) {
} else {
fmt.Fprintf(stdout, "%s %s -> %s\n", colorFor(stdout).ok("already attached"), host, resolvedRoute)
}
+ printDomainDNSLine(ctx, sess, acct, *app, host)
+}
+
+// printDomainDNSLine completes the job `carlos domains attach` promises
+// (spec §5: "Attach output always ends with the DNS records to create and
+// the current stamp — the command completes the job or says exactly what is
+// left"): it re-fetches the domain list and prints the just-attached host's
+// own DNS stamp, so the member never needs a second command (let alone a
+// console visit) to learn whether the domain is live. Degrades quietly on a
+// list failure or a missing row (an old console, or a claim the sweep has
+// not reached yet with no readable answer) — the attach line above already
+// reported the thing that matters (whether the write happened), so a DNS
+// line this call cannot back up is skipped rather than guessed at.
+func printDomainDNSLine(ctx context.Context, sess *apiSession, acct apiAcct, app, host string) {
+ domains, err := sess.domainList(ctx, acct, app)
+ if err != nil {
+ return
+ }
+ for _, d := range domains {
+ if d.Host != host {
+ continue
+ }
+ if d.DNSStatus == "pointing" {
+ fmt.Fprintln(stdout, "DNS: pointing")
+ return
+ }
+ dns := d.DNSStatus
+ if dns == "" {
+ dns = "pending"
+ }
+ if d.Guidance != "" {
+ fmt.Fprintf(stdout, "DNS: %s — %s\n", dns, d.Guidance)
+ } else {
+ fmt.Fprintf(stdout, "DNS: %s\n", dns)
+ }
+ return
+ }
}
func runDomainsDetach(args []string) {
@@ -134,7 +172,7 @@ func runDomainsList(args []string) {
log.Fatalf("domains list: %v", err)
}
w := newTable(stdout)
- fmt.Fprintln(w, "HOST\tROUTE\tSTATUS\tDNS\tCERT-EXPIRES")
+ fmt.Fprintln(w, "HOST\tROUTE\tSTATUS\tDNS\tCERT-EXPIRES\tGUIDANCE")
for _, r := range recs {
dns := r.DNSStatus
if dns == "" {
@@ -144,7 +182,11 @@ func runDomainsList(args []string) {
if r.CertExpiresAt > 0 {
cert = time.Unix(r.CertExpiresAt, 0).UTC().Format("2006-01-02")
}
- fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", r.Host, r.Route, r.Status, dns, cert)
+ guidance := r.Guidance
+ if guidance == "" {
+ guidance = "-"
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", r.Host, r.Route, r.Status, dns, cert, guidance)
}
w.Flush()
}
diff --git a/domains_cli_test.go b/domains_cli_test.go
index d34b56e1d..4139db6ee 100644
--- a/domains_cli_test.go
+++ b/domains_cli_test.go
@@ -233,6 +233,15 @@ func newFakeDomainsConsole(t *testing.T) *fakeDomainsConsole {
{"host": "tickets.example.com", "route": "slug.go.tito.io", "status": "live",
"dns_status": "pointing", "cert_expires_at": 1799999999},
{"host": "new.example.com", "route": "slug.go.tito.io", "status": "pending"},
+ // guide.example.com is not pointing yet and carries guidance —
+ // the row TestRunDomainsAttachPrintsDNSGuidanceWhenNotPointing
+ // and TestRunDomainsListPrintsTable's GUIDANCE-column assertion
+ // exercise, alongside tickets.example.com's pointing case and
+ // new.example.com's no-guidance-yet ("pending", no dns_status
+ // sent at all) case above.
+ {"host": "guide.example.com", "route": "default.go.tito.io", "status": "pending",
+ "dns_status": "elsewhere",
+ "guidance": "Point an A record for guide.example.com at 203.0.113.5. Checks run every few minutes."},
},
})
default:
@@ -260,6 +269,12 @@ func domainsCLIFixture(t *testing.T) *fakeDomainsConsole {
return fc
}
+// TestRunDomainsAttachPrintsAttached also pins the no-infra completion the
+// spec asks for (§5): a successful attach re-fetches the domain list and
+// prints the just-attached host's own DNS stamp — here "pointing", since
+// the fake's fixed GET row for tickets.example.com already carries it — so
+// the command answers "is this live?" without a second `carlos domains
+// list`.
func TestRunDomainsAttachPrintsAttached(t *testing.T) {
fc := domainsCLIFixture(t)
fc.attachCreated = true
@@ -270,8 +285,28 @@ func TestRunDomainsAttachPrintsAttached(t *testing.T) {
if fc.lastAttachBody.Host != "tickets.example.com" || fc.lastAttachBody.Route != "slug.go.tito.io" {
t.Fatalf("console saw body %+v", fc.lastAttachBody)
}
- if !strings.Contains(buf.String(), "attached tickets.example.com -> slug.go.tito.io") {
- t.Fatalf("stdout = %q, want the attached confirmation", buf.String())
+ out := buf.String()
+ if !strings.Contains(out, "attached tickets.example.com -> slug.go.tito.io") {
+ t.Fatalf("stdout = %q, want the attached confirmation", out)
+ }
+ if !strings.Contains(out, "DNS: pointing") {
+ t.Fatalf("stdout = %q, want the DNS stamp line completing the job", out)
+ }
+}
+
+// TestRunDomainsAttachPrintsDNSGuidanceWhenNotPointing pins the other arm:
+// a host whose DNS is not pointing yet gets the console's own guidance text
+// verbatim, ending the command's output with exactly what the member still
+// has to do (spec §5's "completes the job or says exactly what is left").
+func TestRunDomainsAttachPrintsDNSGuidanceWhenNotPointing(t *testing.T) {
+ domainsCLIFixture(t)
+ buf := captureStdout(t)
+
+ runDomains([]string{"attach", "-app", "tito", "-route", "default.go.tito.io", "guide.example.com"})
+
+ want := "DNS: elsewhere — Point an A record for guide.example.com at 203.0.113.5. Checks run every few minutes."
+ if out := buf.String(); !strings.Contains(out, want) {
+ t.Fatalf("stdout = %q, want it to contain %q", out, want)
}
}
@@ -314,8 +349,11 @@ func TestRunDomainsDetachPrintsWasNotAttached(t *testing.T) {
}
}
-// TestRunDomainsListPrintsTable: the list table carries every column and
-// falls back to "-" for the row with no dns_status/cert_expires_at.
+// TestRunDomainsListPrintsTable: the list table carries every column
+// (including the GUIDANCE column added for the no-infra rule — the DNS
+// stamp alone tells a member DNS is wrong, but only guidance tells them
+// what to do about it) and falls back to "-" for a row with no
+// dns_status/cert_expires_at/guidance.
func TestRunDomainsListPrintsTable(t *testing.T) {
domainsCLIFixture(t)
buf := captureStdout(t)
@@ -324,16 +362,18 @@ func TestRunDomainsListPrintsTable(t *testing.T) {
out := buf.String()
for _, want := range []string{
- "HOST", "ROUTE", "STATUS", "DNS", "CERT-EXPIRES",
+ "HOST", "ROUTE", "STATUS", "DNS", "CERT-EXPIRES", "GUIDANCE",
"tickets.example.com", "slug.go.tito.io", "live", "pointing", "2027-01-15",
"new.example.com", "pending",
+ "guide.example.com", "elsewhere",
+ "Point an A record for guide.example.com at 203.0.113.5. Checks run every few minutes.",
} {
if !strings.Contains(out, want) {
t.Fatalf("stdout missing %q:\n%s", want, out)
}
}
- // The pending row's dns/cert columns must read "-", not a stray zero or
- // blank cell that would misalign the table.
+ // The pending row's dns/cert/guidance columns must read "-", not a
+ // stray zero or blank cell that would misalign the table.
lines := strings.Split(out, "\n")
var pendingLine string
for _, l := range lines {