carlos / platform Public

console: the control-plane flag guards the instance doors

Found mid-rollout, and it is the door with the shortest fuse. `carlos deploy`
resolves hosts through instance records, so making the console deployable
means giving it one — and a record is an AUTHORITY TRANSFER (AGENTS.md):
once it exists and the route is `Provisioned`, the provisioning reconciler
owns the row and its removal phase retires the route the moment the record
disappears.

DELETE /api/cli/apps/{account}/{app}/instances/{host} is
svcrole.Allow(svcrole.Instance) and had no control-plane check at all, so one
call — by a person or by a CI credential holding that role — would have taken
the control plane off the air. It now wears the outright refusal trash, purge
and store-destroy wear: clear the flag first.

CREATE and PATCH take the promote pair's split instead. A PERSON must be able
to write the console's record — that is the runbook step this unblocks, and
gating it would close the door the flag exists to keep open — while a service
credential must not acquire the reconciler's authority over the console's
route, and a PATCH is a repoint: the reconciler converges Channel and
Environment FROM this record on every pass.

Both helpers are controlplane.go's, unchanged. Every guard sits straight
after instanceGate — after the family's byte-identical 404, before the
manifest read, the shape check and every write — so a non-member still cannot
learn from these doors that an app here carries the flag, and no branch
reaches a write without passing the guard. Reads, the token mint, enable and
the channel door (already a zero svcrole.Set) are untouched.

Tests: each of the three refusals, a PERSON still creating and patching the
flagged app's record, an ordinary app unaffected on all three, and the
non-member's byte-identical 404 on all three. Each new test was verified to
fail with its own guard removed and no other.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 7705dba5eb95e633405126376c965afb9f8b37f0 parent c205415
3 files changed, +266 −0
  • docs/superpowers/specs/2026-08-23-console-as-an-app-design.md +17 −0
  • internal/console/apiinstances.go +42 −0
  • internal/console/controlplane_test.go +207 −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 6a9b61c62..4ce5a7262 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
@@ -405,6 +405,23 @@ An app carrying the flag:
- **refuses promotion by a service principal.** A person with ops membership
can roll the console; a CI credential cannot. This closes the hole §10 step
ordering would otherwise leave open, without a step-up flow.
+- **refuses an instance-record DELETE outright** (added mid-rollout, 2026-08-28).
+ This is the eighth door and the shortest fuse of the set. `carlos deploy`
+ resolves hosts through `apps/<sqid>/<app>/instances/<host>.json`, so making
+ the console deployable means giving it a record — and a record is an
+ authority transfer: once it exists and the route is `Provisioned`, the
+ provisioning reconciler's removal phase retires the route the moment the
+ record goes. `DELETE /api/cli/apps/{account}/{app}/instances/{host}` is
+ `svcrole.Allow(svcrole.Instance)`, so one call from a person *or* a CI
+ credential holding that role took the control plane off the air. Same class
+ as trash and purge; same refusal. **The console must not become
+ instance-backed until this guard is in place.**
+- **refuses an instance CREATE or PATCH by a service principal.** A person must
+ be able to write the console's record — that is the runbook step the whole
+ §10 sequence turns on — but a CI credential must not acquire the reconciler's
+ authority over the console's route, and PATCH is a repoint: the reconciler
+ converges a route's Channel and Environment *from* this record on every pass.
+ Same person/service split as promote, for the same reason.
- gets §5's two ship-time refusals.
Promotion by a **person** is gated by ops membership, which is already the
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go
index 8139c4452..770559a41 100644
--- a/internal/console/apiinstances.go
+++ b/internal/console/apiinstances.go
@@ -471,6 +471,23 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request)
if !ok {
return
}
+ // A PERSON must be able to write the control plane's own instance
+ // record — that is the act that makes `carlos deploy -app console`
+ // resolve a host at all, and refusing it here would close the door this
+ // family exists to open. A SERVICE credential must not: the record it
+ // would write is the thing the reconciler then owns, and ownership of
+ // the console's route is not a CI token's to acquire (controlplane.go).
+ //
+ // Straight after instanceGate, which is to say after the family's
+ // byte-identical 404 and before the manifest read: a non-member must
+ // never learn from this door that an app here carries the flag.
+ // principalFrom's ok is discarded for the reason handleAPIPromote's
+ // comment gives — cliRoute stashed the principal before the handler
+ // started, so it is already present.
+ p, _ := principalFrom(r.Context())
+ if s.refuseServiceControlPlane(w, p, acct.ID, app, "create its instance records") {
+ return
+ }
ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
defer cancel()
m, ok := s.instanceManifest(ctx, w, acct, app)
@@ -676,6 +693,17 @@ func (s *Server) handleAPIInstanceUpdate(w http.ResponseWriter, r *http.Request)
if !ok {
return
}
+ // Same split as create, for a sharper reason: the provisioning
+ // reconciler converges a route's Channel and Environment FROM this
+ // record on every pass (AGENTS.md), so a PATCH here repoints the
+ // control plane. An operator doing that deliberately is legitimate; a CI
+ // credential doing it is the console being moved by something with no
+ // standing to move it. Placed before the host shape check so that no
+ // branch of this handler reaches a write without passing it.
+ p, _ := principalFrom(r.Context())
+ if s.refuseServiceControlPlane(w, p, acct.ID, app, "change its instance records") {
+ return
+ }
host := r.PathValue("host")
// Shape-checked before the key builder, exactly as GET does it and for
// the same reason: a {host} segment arrives percent-decoded, so
@@ -864,6 +892,20 @@ func (s *Server) handleAPIInstanceDelete(w http.ResponseWriter, r *http.Request)
if !ok {
return
}
+ // The eighth destructive door, and the one with the shortest fuse:
+ // deleting a control-plane app's record is how the provisioning
+ // reconciler's removal phase is TOLD to retire the console's route, so a
+ // single call here takes the control plane off the air. That is the same
+ // class of act as trash, purge and store-destroy, and it wears their
+ // refusal — outright, for anyone, service or person alike, until the
+ // flag is cleared (controlplane.go).
+ //
+ // FIRST, ahead of the shape check and the store: the app is already
+ // past instanceGate's byte-identical 404, and nothing below this line
+ // may run for a flagged app.
+ if s.refuseControlPlane(w, acct, app, "deleted") {
+ return
+ }
host := r.PathValue("host")
ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
defer cancel()
diff --git a/internal/console/controlplane_test.go b/internal/console/controlplane_test.go
index 163d49993..be420b3b3 100644
--- a/internal/console/controlplane_test.go
+++ b/internal/console/controlplane_test.go
@@ -14,6 +14,7 @@ import (
"time"
"github.com/carlosframework/platform/internal/objstore"
+ "github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/svcrole"
)
@@ -864,3 +865,209 @@ func TestControlPlaneShipRefusalNamesTheMintTrap(t *testing.T) {
t.Fatalf("minted ship of an ordinary app = %d %v, want 201", status, out)
}
}
+
+// --- The instance doors ------------------------------------------------------
+//
+// The eighth door, found mid-rollout. `carlos deploy` resolves hosts through
+// instance records, so the console needs one — but a record is an AUTHORITY
+// TRANSFER (AGENTS.md): once it exists and the route is Provisioned, the
+// provisioning reconciler owns the row, and its removal phase retires the
+// route the moment the record disappears. So a single DELETE on this family
+// takes the control plane off the air, and PATCH repoints it.
+//
+// The split is the one the flag already draws everywhere else: destruction is
+// refused outright; the WRITES a person legitimately performs stay open to a
+// person and closed to a CI credential.
+
+// cpInstanceFixture is controlPlaneFixture with the instance feature enabled
+// on the flagged `console` app and on an ordinary `widget` beside it, so
+// every test below can assert the refusal AND that a tenant's identical call
+// is untouched by it.
+//
+// Custom domains deliberately, not the platform apex: the sibling-apex rail
+// in handleAPIInstanceCreate only fires inside <sqid>.<AppsDomain>, and these
+// tests are about the flag, not about that rail.
+func cpInstanceFixture(t *testing.T, label string) (*consoleFixture, string) {
+ t.Helper()
+ fx, token := controlPlaneFixture(t, label)
+ enableInstances(t, fx, "console", provision.Manifest{Schema: 1, App: "console",
+ Domain: "cp.tito.io", Pool: "tito", Channel: "stable", Regions: []string{"ie"}})
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "widget", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ enableInstances(t, fx, "widget", provision.Manifest{Schema: 1, App: "widget",
+ Domain: "widget.tito.io", Pool: "tito", Channel: "stable", Regions: []string{"ie"}})
+ return fx, token
+}
+
+// TestControlPlaneAppRefusesInstanceDelete is the whole reason this slice
+// exists. Deleting the console's instance record is not an edit to a bucket
+// object — it is the instruction the reconciler's removal phase acts on, and
+// the act it performs is retiring the control plane's route. Refused
+// OUTRIGHT, for a person as much as for a service: the same class as trash,
+// purge and store-destroy, and the same way out (clear the flag first).
+func TestControlPlaneAppRefusesInstanceDelete(t *testing.T) {
+ fx, token := cpInstanceFixture(t, "code-cp-inst-del")
+ u := instancesURL(fx, "bab", "console")
+
+ // The record exists first — created by the person the next test proves
+ // may create it — so the refusal below is refusing a delete that would
+ // otherwise have succeeded.
+ if status, _, out := rawReq(t, http.MethodPost, u, token,
+ `{"host":"box1.cp.tito.io","region":"ie"}`); status != http.StatusCreated {
+ t.Fatalf("create = %d %s", status, out)
+ }
+
+ status, _, out := rawReq(t, http.MethodDelete, u+"/box1.cp.tito.io", token, "")
+ if status != http.StatusForbidden {
+ t.Fatalf("a PERSON deleting the control plane's instance record = %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/console/settings") {
+ t.Fatalf("refusal %q does not point at the settings card that can clear the flag", out)
+ }
+ // The record survived. This is the assertion the door exists for: the
+ // reconciler never sees the record go, so the route is never retired.
+ if _, err := provision.GetRecord(context.Background(), fx.rel, "bab", "console", "box1.cp.tito.io"); err != nil {
+ t.Fatalf("the control plane's instance record did not survive a refused delete: %v", err)
+ }
+
+ // A service credential holding the role this door grants meets the same
+ // wall — it is refused for what the app IS, not for what the caller is.
+ svc := serviceToken(t, fx.st, OpsAccountID, "ci", "console", svcrole.Instance)
+ if status, _, out := rawReq(t, http.MethodDelete, u+"/box1.cp.tito.io", svc, ""); status != http.StatusForbidden {
+ t.Fatalf("a SERVICE deleting the control plane's instance record = %d %s, want 403", status, out)
+ }
+
+ // A traversal host on the flagged app is refused by the flag, not by the
+ // shape check: nothing below the guard runs for a control-plane app.
+ if status, _, out := rawReq(t, http.MethodDelete, u+"/..%2f..%2finstances.json", token, ""); status != http.StatusForbidden {
+ t.Fatalf("traversal delete on a flagged app = %d %s, want the flag's 403", status, out)
+ }
+
+ // And an ORDINARY app deletes exactly as it always did.
+ wu := instancesURL(fx, "bab", "widget")
+ if status, _, out := rawReq(t, http.MethodPost, wu, token,
+ `{"host":"box1.widget.tito.io","region":"ie"}`); status != http.StatusCreated {
+ t.Fatalf("ordinary create = %d %s", status, out)
+ }
+ if status, _, out := rawReq(t, http.MethodDelete, wu+"/box1.widget.tito.io", token, ""); status != http.StatusOK {
+ t.Fatalf("ordinary delete = %d %s, want 200 — the flag is not a fleet-wide freeze", status, out)
+ }
+}
+
+// TestControlPlaneAppAllowsAPersonToCreateAndPatchItsInstance is the half
+// that must keep working, and the runbook step this whole slice unblocks:
+// the console has no instance record because it predates provisioning, and
+// `carlos deploy -app console` cannot resolve a host until a PERSON writes
+// one. A create gate here would close the door the flag exists to keep open.
+func TestControlPlaneAppAllowsAPersonToCreateAndPatchItsInstance(t *testing.T) {
+ fx, token := cpInstanceFixture(t, "code-cp-inst-person")
+ u := instancesURL(fx, "bab", "console")
+ if status, _, out := rawReq(t, http.MethodPost, u, token,
+ `{"host":"box1.cp.tito.io","region":"ie","kind":"service","addrs":["127.0.0.1:9001"]}`); status != http.StatusCreated {
+ t.Fatalf("a PERSON creating the control plane's instance record = %d %s, want 201 — this is the runbook step", status, out)
+ }
+ if _, err := provision.GetRecord(context.Background(), fx.rel, "bab", "console", "box1.cp.tito.io"); err != nil {
+ t.Fatalf("the record a person created is not there: %v", err)
+ }
+ if status, _, out := rawReq(t, http.MethodPatch, updateURL(fx, "bab", "console", "box1.cp.tito.io"),
+ token, `{"addrs":["127.0.0.1:9002"]}`); status != http.StatusOK {
+ t.Fatalf("a PERSON patching the control plane's instance record = %d %s, want 200", status, out)
+ }
+ rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "console", "box1.cp.tito.io")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(rec.Addrs) != 1 || rec.Addrs[0] != "127.0.0.1:9002" {
+ t.Fatalf("addrs after a person's patch = %v, want the requested one", rec.Addrs)
+ }
+}
+
+// TestControlPlaneAppRefusesAServiceInstanceCreate: the record a create
+// writes is the thing the reconciler then owns, so minting one for the
+// control plane is acquiring authority over the console's route. A person
+// may; a CI credential may not.
+func TestControlPlaneAppRefusesAServiceInstanceCreate(t *testing.T) {
+ fx, _ := cpInstanceFixture(t, "code-cp-inst-svc-create")
+ svc := serviceToken(t, fx.st, OpsAccountID, "ci", "console", svcrole.Instance)
+ status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "console"), svc,
+ `{"host":"box2.cp.tito.io","region":"ie"}`)
+ if status != http.StatusForbidden {
+ t.Fatalf("a SERVICE creating the control plane's instance record = %d %s, want 403", status, out)
+ }
+ if !strings.Contains(out, "a person must") {
+ t.Fatalf("refusal %q does not say a person is the principal this needs", out)
+ }
+ if _, err := provision.GetRecord(context.Background(), fx.rel, "bab", "console", "box2.cp.tito.io"); err == nil {
+ t.Fatal("a record exists after only a refused service create")
+ }
+
+ // The same credential shape, bound to an ordinary app, creates freely:
+ // nothing about service provisioning became worse for a tenant.
+ ws := serviceToken(t, fx.st, OpsAccountID, "ci-widget", "widget", svcrole.Instance)
+ if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "widget"), ws,
+ `{"host":"box2.widget.tito.io","region":"ie"}`); status != http.StatusCreated {
+ t.Fatalf("a SERVICE creating an ORDINARY app's instance = %d %s, want 201", status, out)
+ }
+}
+
+// TestControlPlaneAppRefusesAServiceInstancePatch: the provisioning
+// reconciler converges a route's Channel and Environment FROM this record on
+// every pass, so a PATCH repoints the control plane. Same split as create.
+func TestControlPlaneAppRefusesAServiceInstancePatch(t *testing.T) {
+ fx, token := cpInstanceFixture(t, "code-cp-inst-svc-patch")
+ u := instancesURL(fx, "bab", "console")
+ if status, _, out := rawReq(t, http.MethodPost, u, token,
+ `{"host":"box3.cp.tito.io","region":"ie","kind":"service","addrs":["127.0.0.1:9001"]}`); status != http.StatusCreated {
+ t.Fatalf("create = %d %s", status, out)
+ }
+ svc := serviceToken(t, fx.st, OpsAccountID, "ci", "console", svcrole.Instance)
+ status, _, out := rawReq(t, http.MethodPatch, updateURL(fx, "bab", "console", "box3.cp.tito.io"), svc,
+ `{"addrs":["10.0.0.1:1"]}`)
+ if status != http.StatusForbidden {
+ t.Fatalf("a SERVICE patching the control plane's instance record = %d %s, want 403", status, out)
+ }
+ if !strings.Contains(out, "a person must") {
+ t.Fatalf("refusal %q does not say a person is the principal this needs", out)
+ }
+ rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "console", "box3.cp.tito.io")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(rec.Addrs) != 1 || rec.Addrs[0] != "127.0.0.1:9001" {
+ t.Fatalf("addrs after a refused service patch = %v, want the created pair untouched", rec.Addrs)
+ }
+
+ // An ordinary app's record is still a service's to patch.
+ ws := serviceToken(t, fx.st, OpsAccountID, "ci-widget", "widget", svcrole.Instance)
+ if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "widget"), ws,
+ `{"host":"box3.widget.tito.io","region":"ie","kind":"service","addrs":["127.0.0.1:9001"]}`); status != http.StatusCreated {
+ t.Fatalf("ordinary create = %d %s", status, out)
+ }
+ if status, _, out := rawReq(t, http.MethodPatch, updateURL(fx, "bab", "widget", "box3.widget.tito.io"), ws,
+ `{"addrs":["127.0.0.1:9002"]}`); status != http.StatusOK {
+ t.Fatalf("a SERVICE patching an ORDINARY app's instance = %d %s, want 200", status, out)
+ }
+}
+
+// TestControlPlaneInstanceRefusalIsNotAnOracle: the three guards sit AFTER
+// the family's membership gate, so a stranger meets the byte-identical 404
+// and never the refusal. A control-plane app must not be discoverable by
+// asking a door which sentence it answers with.
+func TestControlPlaneInstanceRefusalIsNotAnOracle(t *testing.T) {
+ fx, _ := cpInstanceFixture(t, "code-cp-inst-oracle")
+ eve := cliToken(t, fx, "eve@keymail.dev", "code-cp-inst-eve")
+ want := canonicalNotFound(t)
+ u := instancesURL(fx, "bab", "console")
+ for _, c := range []struct{ what, method, url, body string }{
+ {"non-member create on a flagged app", http.MethodPost, u, `{"host":"box9.cp.tito.io","region":"ie"}`},
+ {"non-member patch on a flagged app", http.MethodPatch, u + "/box9.cp.tito.io", `{"addrs":[]}`},
+ {"non-member delete on a flagged app", http.MethodDelete, u + "/box9.cp.tito.io", ""},
+ } {
+ status, h, out := rawReq(t, c.method, c.url, eve, c.body)
+ assertNotFound(t, c.what, want, status, h, out)
+ }
+}