carlos / platform Public

console: the control-plane flag guards the detach doors too

The ninth door, and the same fuse as the eighth. handleAPIDomainDetach's
first-route branch calls the very provision.DeleteRecord the instance delete
does, on the same app, and the route is registered
svcrole.Allow(svcrole.Publish, svcrole.Instance) — so a service credential
reaches it. Guarding the instance family and leaving this open would be a
guard with a published way round it, which reads as protection and is not.

Refused OUTRIGHT for a flagged app, not conditionally on the record-deleting
branch: a conditional would have to re-derive "does this claim mint the app's
route" ahead of the code that derives it, and the two would drift — and
releasing a hostname the fleet reaches the control plane by is not something
to do in passing. The browser twin (detachDomain, shared by
handleAppDomainDetach) wears the same refusal in its doorRefusal form, because
a guard only on the API door would leave the same route retirable by a form
post.

Placement is the family's: after the membership gate, before the hostname is
normalized and before any write — so a flagged app's answer at this door is
constant across valid and malformed hosts, and a non-member still meets the
byte-identical 404.

Spec §6: the heading now says the flag narrows a service principal's
authority as well as gating destruction, and the **Test:** paragraph
enumerates the instance and detach doors so it describes the tests that
exist.

Tests: the API detach refused for a person and for BOTH service roles the
route admits, the browser detach refused for the owner, the claim surviving
each, a malformed host meeting the same 403, the non-member's byte-identical
404, and — the control — clearing the flag letting the identical call through,
so the flag is the only variable between a refusal and a 200. Both new guards
were verified to fail their own test when removed, and no other's.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 5b1f4fd9a4de3b9a28cf6556cfc462c6b7fe68db parent 7705dba
4 files changed, +187 −6
  • docs/superpowers/specs/2026-08-23-console-as-an-app-design.md +13 −6
  • internal/console/apidomains.go +24 −0
  • internal/console/controlplane_test.go +139 −0
  • internal/console/domainattach.go +11 −0
diff --git a/docs/superpowers/specs/2026-08-23-console-as-an-app-design.md b/docs/superpowers/specs/2026-08-23-console-as-an-app-design.md
index 4ce5a7262..56bb06775 100644
--- a/docs/superpowers/specs/2026-08-23-console-as-an-app-design.md
+++ b/docs/superpowers/specs/2026-08-23-console-as-an-app-design.md
@@ -375,7 +375,7 @@ declared target and what was found; a binary whose embedded build ≠ `-version`
is refused naming both; a matching artifact ships; a non-control-plane app is
unaffected; a non-ELF artifact (a static tarball) is unaffected.
-## 6. The control-plane flag gates DESTRUCTION, not promotion
+## 6. The control-plane flag gates DESTRUCTION, not promotion — and narrows a service principal's authority
```sql
ALTER TABLE apps ADD COLUMN control_plane INTEGER NOT NULL DEFAULT 0;
@@ -435,11 +435,18 @@ no-box-commands corollary. It is set at claim time: `carlos apps create` gains
does not).
**Test:** trash/purge/rename/transfer on a flagged app are refused naming the
-flag; a service principal's promote is refused; a person's promote at any rung
-succeeds with ordinary membership; clearing the flag requires fresh auth;
-setting it requires owner; an unflagged app is unaffected in every case; the
-refusal sits behind the existing 404 so it does not leak the flag's existence
-to a non-member.
+flag; the instance-record DELETE is refused for a person AND for a service, and
+the record survives; a domain detach is refused for a person and a service at
+the API door and for the owner at the browser door, and the claim survives; a
+service principal's promote, retention change, instance create and instance
+patch are each refused; a person's promote at any rung succeeds with ordinary
+membership, and a person can still CREATE and PATCH the flagged app's instance
+record — asserted on the stored record, not on a status code, because that half
+is the runbook step the flag must not break; clearing the flag requires fresh
+auth; setting it requires owner; an unflagged app is unaffected in every case;
+and every refusal sits behind the existing 404, so a non-member probing any of
+these doors gets the byte-identical not-found and never learns the flag's
+existence.
## 7. The version stamp and the dirty tree
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go
index 071846791..d6760b9b0 100644
--- a/internal/console/apidomains.go
+++ b/internal/console/apidomains.go
@@ -508,6 +508,30 @@ func (s *Server) handleAPIDomainDetach(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
+ // The NINTH control-plane door, and the same fuse as the instance
+ // delete: the first-route branch below calls the very
+ // provision.DeleteRecord that retires a route, on the same app, and this
+ // door is reachable by a service credential (svcrole.Publish OR
+ // svcrole.Instance). Guarding the instance family and leaving this open
+ // would be a guard with a published way round it, which reads as
+ // protection and is not — the same reasoning the account-deletion
+ // refusal was built on.
+ //
+ // OUTRIGHT, and NOT conditional on the record-deleting branch. Two
+ // reasons. A conditional guard is a bug surface: it would have to
+ // re-derive "does this claim mint the route" ahead of the code that
+ // derives it, and the two would drift. And detaching a domain from the
+ // control plane is not an act anyone should perform without clearing the
+ // flag first — a hostname the fleet reaches the console by is not
+ // released in passing.
+ //
+ // Straight after domainsGate — after the family's byte-identical 404,
+ // before the hostname is even normalized — so no branch reaches a write
+ // without passing it, and a flagged app's answer here is CONSTANT across
+ // valid and malformed hosts rather than leaking shape-validity.
+ if s.refuseControlPlane(w, acct, app, "detached") {
+ return
+ }
principal := auditActor(p, acct)
// Shape-checked before the key builder, not after: a {host} segment
// arrives percent-decoded, so "..%2f..%2finstances.json" reaches this
diff --git a/internal/console/controlplane_test.go b/internal/console/controlplane_test.go
index be420b3b3..9e2123b0e 100644
--- a/internal/console/controlplane_test.go
+++ b/internal/console/controlplane_test.go
@@ -1071,3 +1071,142 @@ func TestControlPlaneInstanceRefusalIsNotAnOracle(t *testing.T) {
assertNotFound(t, c.what, want, status, h, out)
}
}
+
+// --- The detach doors --------------------------------------------------------
+//
+// The NINTH door, and the same fuse as the eighth: handleAPIDomainDetach's
+// first-route branch calls the very provision.DeleteRecord the instance
+// delete does, on the same app, and the route is registered
+// svcrole.Allow(svcrole.Publish, svcrole.Instance) — so a service credential
+// reaches it. Its browser twin is detachDomain, shared by
+// handleAppDomainDetach.
+//
+// Refused OUTRIGHT rather than only on the record-deleting branch. A
+// conditional guard would have to re-derive "does this claim mint the app's
+// route" ahead of the code that derives it, and the two would drift; and
+// releasing a hostname the fleet reaches the control plane by is not
+// something to do in passing.
+
+// cpDetachFixture attaches a hostname to `kass` through the shared core and
+// THEN sets the flag — the order a real control plane arrives in (the console
+// had its hostname long before anyone thought to mark it), and the only order
+// in which the attach half is reachable at all.
+//
+// The control in these tests is the SAME app with the flag cleared rather
+// than a sibling app, deliberately: it isolates the flag as the only variable
+// between a refusal and a 200, which a second app with its own routes and
+// claims could not do as cleanly.
+func cpDetachFixture(t *testing.T, label, host string) (*consoleFixture, string) {
+ t.Helper()
+ fx := newConsoleFixture(t)
+ acct, err := fx.st.AccountBySqid(fx.sqid)
+ if err != nil || acct == nil {
+ t.Fatalf("fixture account %s: %v", fx.sqid, err)
+ }
+ if _, ref := fx.srv.attachDomain(context.Background(), acct, "kass", host, "", "", "paul@keymail.dev"); ref != nil {
+ t.Fatalf("attach setup: refused %+v", ref)
+ }
+ if err := fx.st.SetAppControlPlane(OpsAccountID, "kass", true); err != nil {
+ t.Fatal(err)
+ }
+ return fx, cliToken(t, fx, "paul@keymail.dev", label)
+}
+
+// TestControlPlaneAppRefusesDomainDetach: the API door. Refused for a person
+// and for BOTH service roles the route admits, the claim survives, and
+// clearing the flag is the only way through — which is what proves the
+// refusal is the flag's and nothing else's.
+func TestControlPlaneAppRefusesDomainDetach(t *testing.T) {
+ const host = "docs.example.com"
+ fx, token := cpDetachFixture(t, "code-cp-detach", host)
+ u := fx.ts.URL + "/api/cli/apps/bab/kass/domains/" + host
+
+ status, _, out := rawReq(t, http.MethodDelete, u, token, "")
+ if status != http.StatusForbidden {
+ t.Fatalf("a PERSON detaching a control-plane app's domain = %d %s, want 403", status, out)
+ }
+ if !strings.Contains(out, "control plane") {
+ t.Fatalf("refusal %q does not name the flag the operator has to clear", out)
+ }
+ if !strings.Contains(out, "/a/bab/apps/kass/settings") {
+ t.Fatalf("refusal %q does not point at the settings card that can clear the flag", out)
+ }
+ claimOf(t, fx, host)
+
+ // Both roles the route admits, since either would otherwise walk round
+ // the instance family's guard by taking this door instead.
+ for _, role := range []svcrole.Role{svcrole.Publish, svcrole.Instance} {
+ svc := serviceToken(t, fx.st, OpsAccountID, "ci-"+string(role), "kass", role)
+ if status, _, out := rawReq(t, http.MethodDelete, u, svc, ""); status != http.StatusForbidden {
+ t.Fatalf("a SERVICE (%s) detaching a control-plane app's domain = %d %s, want 403", role, status, out)
+ }
+ }
+ claimOf(t, fx, host)
+
+ // A malformed host answers the SAME 403, not the hostname check's 400:
+ // on a flagged app this door's answer is constant, so it leaks nothing
+ // about which hosts are even well-shaped here.
+ if status, _, out := rawReq(t, http.MethodDelete,
+ fx.ts.URL+"/api/cli/apps/bab/kass/domains/..%2f..%2fclaims", token, ""); status != http.StatusForbidden {
+ t.Fatalf("malformed host on a flagged app = %d %s, want the flag's 403", status, out)
+ }
+
+ // Clear the flag and the identical call goes through. The flag is the
+ // only variable between these two lines.
+ if err := fx.st.SetAppControlPlane(OpsAccountID, "kass", false); err != nil {
+ t.Fatal(err)
+ }
+ status, _, out = rawReq(t, http.MethodDelete, u, token, "")
+ if status != http.StatusOK || !strings.Contains(out, `"removed":true`) {
+ t.Fatalf("detach once the flag is cleared = %d %s, want 200 removed true", status, out)
+ }
+ claimAbsent(t, fx, host)
+}
+
+// TestControlPlaneAppRefusesBrowserDomainDetach: the browser twin deletes the
+// same record through the same core, so a guard only on the API door would
+// leave the route retirable by a form post. Rendered as a page, like every
+// other browser refusal, and ahead of the claim read.
+func TestControlPlaneAppRefusesBrowserDomainDetach(t *testing.T) {
+ const host = "docs.example.com"
+ fx, _ := cpDetachFixture(t, "code-cp-detach-browser", host)
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ status, _, body := postForm(t, c, fx.u("/apps/kass/domains/detach"),
+ url.Values{"csrf": {csrf}, "host": {host}, "confirm": {host}})
+ if status != http.StatusForbidden {
+ t.Fatalf("owner detaching a control-plane app's domain in a browser = %d, want 403:\n%s", status, body)
+ }
+ if !strings.Contains(body, "control plane") {
+ t.Fatalf("browser refusal does not name the flag:\n%s", body)
+ }
+ if !strings.Contains(body, "/a/bab/apps/kass/settings") {
+ t.Fatalf("browser refusal does not point at the settings card that can clear the flag:\n%s", body)
+ }
+ claimOf(t, fx, host)
+
+ // Cleared, the same form post lands.
+ if err := fx.st.SetAppControlPlane(OpsAccountID, "kass", false); err != nil {
+ t.Fatal(err)
+ }
+ if status, loc, body := postForm(t, c, fx.u("/apps/kass/domains/detach"),
+ url.Values{"csrf": {csrf}, "host": {host}, "confirm": {host}}); status != http.StatusSeeOther ||
+ loc != fx.path("/apps/kass/settings") {
+ t.Fatalf("browser detach once the flag is cleared = %d -> %q, want 303 -> settings:\n%s", status, loc, body)
+ }
+ claimAbsent(t, fx, host)
+}
+
+// TestControlPlaneDomainDetachRefusalIsNotAnOracle: the guard sits after
+// domainsGate, so a stranger meets the family's byte-identical 404 and never
+// the sentence that would tell them this account holds a control-plane app.
+func TestControlPlaneDomainDetachRefusalIsNotAnOracle(t *testing.T) {
+ const host = "docs.example.com"
+ fx, _ := cpDetachFixture(t, "code-cp-detach-oracle", host)
+ eve := cliToken(t, fx, "eve@keymail.dev", "code-cp-detach-eve")
+ status, h, out := rawReq(t, http.MethodDelete,
+ fx.ts.URL+"/api/cli/apps/bab/kass/domains/"+host, eve, "")
+ assertNotFound(t, "non-member detach on a flagged app", canonicalNotFound(t), status, h, out)
+ claimOf(t, fx, host)
+}
diff --git a/internal/console/domainattach.go b/internal/console/domainattach.go
index baff49f74..7e7646e71 100644
--- a/internal/console/domainattach.go
+++ b/internal/console/domainattach.go
@@ -223,6 +223,17 @@ func (s *Server) detachDomain(ctx context.Context, acct *Account, app, host, pri
if ref := s.bucketRefusal(); ref != nil {
return ref
}
+ // The browser twin of handleAPIDomainDetach's control-plane refusal, and
+ // it has to be here rather than in the door above it: this core is what
+ // deletes the provision record, so a guard the browser path did not
+ // share would leave the same route retirable by the same mechanism
+ // through a form post. Refused outright for a flagged app, for the
+ // reasons the API door's comment gives, and BEFORE the claim is read —
+ // its own caller (handleAppDomainDetach) has already spent the session,
+ // CSRF and owner gates, so a non-member never reaches this sentence.
+ if msg, status := s.controlPlaneRefusal(acct, app, "detached"); msg != "" {
+ return &doorRefusal{status, msg}
+ }
notAttached := &doorRefusal{http.StatusNotFound, "not attached to this app"}
// A host that is not even a hostname cannot be a claim key: refuse it
// here rather than handing it to a store as a key segment.