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>
5 files changed,
+197
−16
deploy.go+18 −2deploy_test.go+78 −2docs/superpowers/specs/2026-08-12-domain-attach-design.md+8 −1domains_cli.go+46 −4domains_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 { |