carlos / platform Public

domains: detach releases the first-route record; e2e proves the whole loop

The CLI DELETE door now mirrors attach's write order for a first-route
claim (empty Route): the declared provision record goes BEFORE the claim,
so a detach can never leave a record materializing a route for a host
anyone may now claim. Only the caller's own first-route claim takes the
branch — every other shape keeps DetachClaim's quiet neutrality untouched.

domains_e2e_test.go is the acceptance loop through the real binary against
a real console.Server: carlos deploy -kind static -host <h> attaches on
the way through (claim + static record verified in the bucket, exit text
is the DNS guidance), a second attach from a different account gets
exactly "hostname unavailable" with no holder named, carlos domains list
shows the owner the pending claim, and detach removes record then claim.
carlosTestEnv + cmd.Dir scratch throughout (the repo rule).

Spec and plan docs updated to the merged refusal wording.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 2a29b63ed22455c259d94dfd952d153dd6b3d849 parent e79d0d6
4 files changed, +250 −6
  • docs/superpowers/plans/2026-08-12-domain-attach.md +5 −5
  • docs/superpowers/specs/2026-08-12-domain-attach-design.md +1 −1
  • domains_e2e_test.go +228 −0
  • internal/console/apidomains.go +16 −0
diff --git a/docs/superpowers/plans/2026-08-12-domain-attach.md b/docs/superpowers/plans/2026-08-12-domain-attach.md
index 6d4644eab..b2a63df0e 100644
--- a/docs/superpowers/plans/2026-08-12-domain-attach.md
+++ b/docs/superpowers/plans/2026-08-12-domain-attach.md
@@ -140,7 +140,7 @@ Expected: FAIL — package does not exist / functions undefined.
// domains/<host>.json — the create-only record whose successful create IS
// the fleet-wide uniqueness check for an attached hostname (first writer
// wins; a lost create surfaces release.ErrCASConflict and the door answers
-// "host unavailable" without naming the holder).
+// "hostname unavailable" without naming the holder).
//
// INTERIM (#82/#151 cross-session agreement, 2026-08-12): #151's branch
// (worktree-route-hostnames) owns the canonical claim writer; this package
@@ -318,7 +318,7 @@ Expected: FAIL — `attachHostRefusal` undefined.
// domain is refused before any claim is written.
// First-writer-wins is NOT here: the claim's create-only write is the
// entire uniqueness check, and its refusal is the door's neutral
-// "host unavailable".
+// "hostname unavailable".
package console
import (
@@ -409,7 +409,7 @@ Behavior contract (from spec §3–§4), which the tests below pin:
If CreateRecord fails after the claim landed, best-effort
`domainclaim.Delete` then return 500.
4. `domainclaim.Create` losing the race (`errors.Is(err, release.ErrCASConflict)`)
- → 409 with exactly `"host unavailable"` — including when the caller's
+ → 409 with exactly `"hostname unavailable"` — including when the caller's
own account holds it (no owner leak; GET is where you see your own).
5. Success: `s.audit(acct, app, principal, "domain-attach", ...)` +
`s.bumpSerial(ctx)` inside the core (both doors get it for free);
@@ -434,7 +434,7 @@ func TestAttachAliasWhenAppHasReadyRoute(t *testing.T) // registry fixture w
func TestAttachFirstRouteWritesClaimThenRecord(t *testing.T) // no routes, one shipped channel → claim with empty Route AND a parseable provision.Record at RecordKey(sqid, app, host) with Kind static + that channel
func TestAttachAmbiguousChannelRefusesNamingAll(t *testing.T) // two channels, no channelFlag → 422, msg contains both names, NO claim written
func TestAttachProxiedKindWithoutRouteRefused(t *testing.T) // kindFlag "instance", no routes → 422, no claim
-func TestAttachHeldHostIsNeutral409(t *testing.T) // pre-created claim by another account → 409, msg exactly "host unavailable"
+func TestAttachHeldHostIsNeutral409(t *testing.T) // pre-created claim by another account → 409, msg exactly "hostname unavailable"
func TestAttachPlatformSpaceRefused(t *testing.T) // host under cfg.AppsDomain → 422, no claim
func TestDetachFirstRouteDeletesRecordThenClaim(t *testing.T)
func TestDetachForeignClaimIs404(t *testing.T) // claim held by another account+app → 404 refusal
@@ -621,7 +621,7 @@ func (s *apiSession) domainAttach(ctx context.Context, a apiAcct, app, host, kin
Wire contract (agreed with #151; the server half arrives in Task 8):
`POST /api/cli/apps/{account}/{app}/domains/{host}` body
`{"kind":"static","channel":"canary/live"}` (both optional) → 201 with an
-`apiDomain`; 409 body contains `host unavailable`; 422 refusal text
+`apiDomain`; 409 body contains `hostname unavailable`; 422 refusal text
passes through to the user verbatim.
Behavior in `runDeploy`: when `-host` is set and the resolved static
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 90c751c54..ecaee696c 100644
--- a/docs/superpowers/specs/2026-08-12-domain-attach-design.md
+++ b/docs/superpowers/specs/2026-08-12-domain-attach-design.md
@@ -155,7 +155,7 @@ it:
are foreign domains by definition.
- **First-writer-wins**: the global claim's create-only write is the
entire uniqueness check. A lost create — whatever account holds the
- claim — returns the same neutral "host unavailable" with no owner
+ claim — returns the same neutral "hostname unavailable" with no owner
leaked. A host that is routed on some box but unclaimed (operator
routes predating this) is handled box-side, not console-side: reconcile
finds a differing existing route, leaves it alone, and logs — inert,
diff --git a/domains_e2e_test.go b/domains_e2e_test.go
new file mode 100644
index 000000000..9e0b26919
--- /dev/null
+++ b/domains_e2e_test.go
@@ -0,0 +1,228 @@
+// End-to-end acceptance for the domain-attach half of #82 (Task 8), driven
+// through the REAL binary against a REAL console.Server: `carlos deploy
+// -kind static -host <h>` attaches an unclaimed custom domain on its way
+// through (claim + declared record in the bucket), a second attach from a
+// DIFFERENT account is refused with the neutral "hostname unavailable" and
+// nothing else, and `carlos domains list -app` shows the member their own
+// claim. Subprocesses throughout, because the feature's surface is the
+// binary's own composition (deploy → attach POST → ship → promote → watch →
+// DNS guidance) and the refusal text on a real stderr is the thing the spec
+// pins.
+//
+// Env discipline: every subprocess builds its env through carlosTestEnv AND
+// sets cmd.Dir to a scratch dir (the repo rule — the binary walks cwd
+// ancestors for .carlos/config, and a logged-in laptop once minted 40+ real
+// accounts on the production console). The explicit CARLOS_CREDENTIALS
+// appended after carlosTestEnv's own wins (os/exec keeps the LAST duplicate),
+// landing each subprocess on exactly the credential file this test minted.
+package main
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/console"
+ "github.com/carlosframework/platform/internal/domains"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// e2eWriteCredential mints a credential file at path for a device-flow token
+// against consoleURL — the state `carlos auth login` leaves behind, minted
+// through the console's real approve/poll handlers (e2eDeviceLogin).
+func e2eWriteCredential(t *testing.T, path, consoleURL, addr, token string, expiresAt time.Time) {
+ t.Helper()
+ t.Setenv("CARLOS_CREDENTIALS", path)
+ credFile, err := loadCredentials()
+ if err != nil {
+ t.Fatal(err)
+ }
+ credFile.put(credential{Console: consoleURL, Address: addr, Token: token, ExpiresAt: expiresAt})
+ if err := credFile.save(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestE2EDomainsAttachThroughDeploy(t *testing.T) {
+ bin := buildCarlos(t)
+ const (
+ ownerAddr = "paul@keymail.dev"
+ rivalAddr = "rival@keymail.dev"
+ app = "sitely"
+ rivalApp = "rival"
+ channel = "canary/site"
+ // .test never resolves, so the watch leg's connection-class failure —
+ // the shape a domain nobody has pointed yet produces — is hermetic.
+ host = "e2e-domains.example.test"
+ )
+
+ tmp := t.TempDir()
+ relStore := release.NewDirStore(filepath.Join(tmp, "store"))
+ seed, pubB64, err := release.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ priv, err := release.ParseSigningKey(seed, "e2e")
+ if err != nil {
+ t.Fatal(err)
+ }
+ pub, err := release.ParsePublicKey(pubB64, "e2e")
+ if err != nil {
+ t.Fatal(err)
+ }
+ cst, err := console.Open(filepath.Join(tmp, "console.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { cst.Close() })
+
+ var srv *console.Server
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ srv.ServeHTTP(w, r)
+ }))
+ t.Cleanup(ts.Close)
+ srv, err = console.NewServer(console.Config{
+ Origin: ts.URL,
+ Keymail: "http://keymail.invalid", // never dialled — see e2eDeviceLogin
+ Releases: relStore,
+ ReleasePub: pub,
+ Key: priv,
+ AppScheme: "http",
+ AppsDomain: "oncarlos.test",
+ }, cst)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Account 1: the ops owner and the static app the deploy targets.
+ if err := cst.AddMember(console.OpsAccountID, ownerAddr, console.RoleOwner, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := cst.CreateAppIn(console.OpsAccountID, app, ownerAddr, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ // Account 2: a different tenant entirely — the prober the neutrality
+ // half of this test speaks as.
+ const rivalID, rivalSqid = 42, "cdef"
+ if err := cst.CreateAccount(rivalID, rivalSqid, "Rival", rivalAddr, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := cst.CreateAppIn(rivalID, rivalApp, rivalAddr, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+
+ ownerToken, ownerExp := e2eDeviceLogin(t, ts.URL, cst, ownerAddr, "e2e@owner")
+ rivalToken, rivalExp := e2eDeviceLogin(t, ts.URL, cst, rivalAddr, "e2e@rival")
+ ownerCreds := filepath.Join(t.TempDir(), "owner-credentials")
+ rivalCreds := filepath.Join(t.TempDir(), "rival-credentials")
+ e2eWriteCredential(t, ownerCreds, ts.URL, ownerAddr, ownerToken, ownerExp)
+ e2eWriteCredential(t, rivalCreds, ts.URL, rivalAddr, rivalToken, rivalExp)
+
+ siteDir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v1"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ run := func(creds string, args ...string) (string, error) {
+ cmd := exec.Command(bin, args...)
+ cmd.Env = carlosTestEnv(t, "CARLOS_CREDENTIALS="+creds,
+ "CARLOS_DEPLOYMENT_BUCKET=", "CARLOS_DEPLOYMENT_DIR=")
+ cmd.Dir = t.TempDir() // no .carlos/config ancestor — the repo rule
+ out, err := cmd.CombinedOutput()
+ return string(out), err
+ }
+
+ // --- 1. deploy to a fresh static host: the attach happens on the way --
+ out, err := run(ownerCreds, "deploy", "-app", app, "-account", "ops", "-kind", "static",
+ "-version", "sha0001", "-channel", channel, "-host", host, "-console", ts.URL,
+ "-timeout", "3s", siteDir)
+ // The deploy itself succeeds (attach + ship + promote) and then exits
+ // NONZERO on the watch: nobody has pointed DNS for the host, and a zero
+ // exit would claim a URL that does not serve. The exit text is the DNS
+ // guidance, not "deploy failed".
+ if err == nil {
+ t.Fatalf("deploy to an unpointed domain exited 0:\n%s", out)
+ }
+ for _, want := range []string{
+ "attached " + host + " (first route: static, channel " + channel + ")",
+ "shipped " + app + " sha0001",
+ "promoted " + app + " sha0001 -> " + channel,
+ "DNS for " + host + " is not pointing at this deployment yet",
+ } {
+ if !strings.Contains(out, want) {
+ t.Fatalf("deploy output missing %q:\n%s", want, out)
+ }
+ }
+ // The two bucket writes the attach is made of, read back directly.
+ claim, err := domains.GetClaim(context.Background(), relStore, host)
+ if err != nil {
+ t.Fatalf("claim after deploy: %v", err)
+ }
+ if claim.Account != release.OpsAccount || claim.App != app || claim.Route != "" {
+ t.Fatalf("claim = %+v, want a first-route claim (empty route) held by ops/%s", claim, app)
+ }
+ rec, err := provision.GetRecord(context.Background(), relStore, release.OpsAccount, app, host)
+ if err != nil {
+ t.Fatalf("record after deploy: %v", err)
+ }
+ if rec.Kind != "static" || rec.Channel != channel || rec.Slug != "" {
+ t.Fatalf("record = %+v, want a static record on %s", *rec, channel)
+ }
+
+ // --- 2. the same host from ANOTHER account: neutral refusal ----------
+ out, err = run(rivalCreds, "domains", "attach", "-app", rivalApp, "-account", rivalSqid,
+ "-route", "web.cdef.oncarlos.test", "-console", ts.URL, host)
+ if err == nil {
+ t.Fatalf("rival attach of a held host exited 0:\n%s", out)
+ }
+ if !strings.Contains(out, "hostname unavailable") {
+ t.Fatalf("rival attach refusal missing the neutral \"hostname unavailable\":\n%s", out)
+ }
+ // Neutral means NEUTRAL: no owner, no account, no app of the holder's in
+ // the refusal — the door must not be a hostname-ownership oracle.
+ for _, leak := range []string{release.OpsAccount, app, ownerAddr} {
+ if strings.Contains(out, leak) {
+ t.Fatalf("refusal leaks the holder (%q):\n%s", leak, out)
+ }
+ }
+ // And the claim is untouched.
+ if after, err := domains.GetClaim(context.Background(), relStore, host); err != nil ||
+ after.Account != release.OpsAccount || after.App != app {
+ t.Fatalf("claim after rival attach = %+v (err %v), want the original untouched", after, err)
+ }
+
+ // --- 3. the owner lists it -------------------------------------------
+ out, err = run(ownerCreds, "domains", "list", "-app", app, "-account", "ops", "-console", ts.URL)
+ if err != nil {
+ t.Fatalf("domains list: %v\n%s", err, out)
+ }
+ if !strings.Contains(out, host) || !strings.Contains(out, "pending") {
+ t.Fatalf("domains list missing %s as pending:\n%s", host, out)
+ }
+
+ // --- 4. detach releases it, and the record goes with it --------------
+ out, err = run(ownerCreds, "domains", "detach", "-app", app, "-account", "ops", "-console", ts.URL, host)
+ if err != nil {
+ t.Fatalf("domains detach: %v\n%s", err, out)
+ }
+ if !strings.Contains(out, "detached "+host) {
+ t.Fatalf("detach output:\n%s", out)
+ }
+ if _, err := domains.GetClaim(context.Background(), relStore, host); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("claim after detach: %v, want gone", err)
+ }
+ // The record goes with the claim (detach's record-then-claim order): a
+ // record outliving its claim would keep a route materializing for a
+ // host anyone may now claim.
+ if _, err := provision.GetRecord(context.Background(), relStore, release.OpsAccount, app, host); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("record after detach: %v, want gone", err)
+ }
+}
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go
index 284ae5d6d..4d4807519 100644
--- a/internal/console/apidomains.go
+++ b/internal/console/apidomains.go
@@ -275,6 +275,22 @@ func (s *Server) handleAPIDomainDetach(w http.ResponseWriter, r *http.Request) {
}
ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
defer cancel()
+ // The first-route half (#82): a claim with an empty Route minted the
+ // app's own primary route via a declared provision record, and that
+ // record must go BEFORE the claim — the mirror of attach's claim-then-
+ // record order, so a failure here cannot leave a record materializing a
+ // route for a host anyone may now claim. Only the caller's own
+ // first-route claim takes this branch; every other shape (absent,
+ // another owner's, an alias) skips it silently and DetachClaim's own
+ // neutrality answers as before.
+ if cur, err := domains.GetClaim(ctx, s.cfg.Releases, host); err == nil &&
+ cur.Account == acct.Sqid && cur.App == app && cur.Route == "" {
+ if err := provision.DeleteRecord(ctx, s.cfg.Releases, acct.Sqid, app, host); err != nil {
+ log.Printf("console: api domains detach %s/%s %s: record delete: %v", acct.Sqid, app, host, err)
+ jsonError(w, http.StatusBadGateway, "could not detach the hostname — try again")
+ return
+ }
+ }
removed, err := domains.DetachClaim(ctx, s.cfg.Releases, host, acct.Sqid, app)
if err != nil {
log.Printf("console: api domains detach %s/%s %s: %v", acct.Sqid, app, host, err)