console: the control-plane flag closes the provision-record lifecycle
The tenth door and the last one: `POST .../domains`'s first-route branch
WRITES a declared provision.Record, which is the same authority transfer
`POST .../instances` already refuses a service for — so without this a CI
credential acquires reconciler authority over a flagged app's route simply by
taking the other door.
Service-only, not outright. A person attaching a domain to the control plane
is an ordinary operator act, the same class as writing its instance record;
removal is the asymmetric half. Placed straight after domainsGate, so it
covers the static branch that delegates to the shared core as well as the
alias branch, and it does not disturb the neutrality ordering the alias
branch's own comment protects: that refusal is about which hosts exist and is
readable by any caller, while this one fires only for a service already proven
bound to this account and app. The browser twin needs no counterpart — a
session's principal is always a person, which is also why the shared core
takes an audit-actor string rather than a Principal.
Spec §6 now states the CLOSURE PRINCIPLE rather than a list: the flag covers
the provision.Record lifecycle, because a record is what grants the
reconciler authority over a route. Create and mutate refuse a service; remove
refuses outright. A future door touching that lifecycle needs the same guard
by the same argument; one that does not, does not.
Audited the lifecycle for an eleventh door. Two sites carry no control_plane
call and are covered anyway, now written down so the next audit does not read
them as gaps: `POST .../instances/{host}/channel` mutates a record but is
registered with the zero svcrole.Set{}, which excludes every service
principal more strictly than a refusal would, and a person repointing the
channel is legitimate; and rollbackAttach is not a door but the compensating
delete inside the attach path, removing only the record the same call just
tried to write.
Tests: both service roles the domains routes admit, in BOTH request shapes
(alias and kind:"static") to show the guard is not conditional on the branch;
a person still attaches; a non-member still gets the byte-identical 404; and
the same-app-with-the-flag-cleared control, where the identical service call
lands 200. Verified to fail with the guard removed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
3 files changed,
+146
−4
docs/superpowers/specs/2026-08-23-console-as-an-app-design.md+46 −4internal/console/apidomains.go+32 −0internal/console/controlplane_test.go+68 −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 56bb06775..24c2e339b 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 |
| @@ -422,8 +422,45 @@ An app carrying the flag: |
| 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. |
| +- **refuses a domain DETACH outright, and a domain ATTACH by a service |
| + principal.** `DELETE /api/cli/apps/{account}/{app}/domains/{host}`'s |
| + first-route branch calls the same `provision.DeleteRecord` the instance |
| + delete does, and `POST .../domains`'s first-route branch *writes* a declared |
| + record — both registered `svcrole.Allow(svcrole.Publish, svcrole.Instance)`, |
| + so a service credential reaches either. Guarding the instance family and |
| + leaving these open would be a guard with a published way round it. Detach's |
| + browser twin (`detachDomain`) carries the same outright refusal; attach's |
| + needs none, because a browser session's principal is always a person. |
| + Neither guard is conditional on the record-touching 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. |
| - gets §5's two ship-time refusals. |
| +**The closure principle — read this before adding a tenth door.** The set above |
| +is not a list to extend by taste; it is the **`provision.Record` lifecycle**, |
| +closed. A record is what grants the provisioning reconciler authority over a |
| +route, so every door that CREATES, MUTATES or REMOVES one for a flagged app |
| +carries a guard: create (`POST .../instances`, `POST .../domains`) and mutate |
| +(`PATCH .../instances/{host}`) refuse a **service principal**, because a person |
| +performing them is a legitimate operator act; remove |
| +(`DELETE .../instances/{host}`, `DELETE .../domains/{host}`) refuses |
| +**outright**, because removal is destruction and belongs with trash and purge. |
| +A future door that touches that lifecycle needs the same guard by the same |
| +argument, and one that does not, does not. |
| + |
| +Two lifecycle sites carry no `control_plane` call and are still covered, which |
| +is worth writing down so the next audit does not read them as gaps. |
| +`POST .../instances/{host}/channel` (`channelmove.go`) MUTATES a record, and is |
| +registered with the **zero `svcrole.Set{}`** — no service principal reaches it |
| +at all, admin included, which enforces the service half more strictly than a |
| +refusal would; a person repointing the control plane's channel is the same |
| +legitimate operator act as promoting it. And `rollbackAttach` |
| +(`domainattach.go`) deletes a record, but it is not a door: it is the |
| +compensating delete inside the attach path, reachable only once that path's own |
| +guard has passed, and it removes only the record the same call just tried to |
| +write — guarding it would strand half-written attaches instead of protecting |
| +anything. |
| + |
| Promotion by a **person** is gated by ops membership, which is already the |
| operator boundary, and by nothing else. That is what keeps |
| `carlos deploy -app console` working as an ordinary command. |
| @@ -438,11 +475,16 @@ does not). |
| 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 |
| +service principal's promote, retention change, instance create, instance patch |
| +and domain attach are each refused — the attach in BOTH request shapes, alias |
| +and first-route, since the guard is not conditional on the branch — and each |
| +for both service roles the domains routes admit; 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 |
| +record and ATTACH a domain to it — asserted on the stored record and the stored |
| +claim, not on a status code, because that half is the runbook step the flag must |
| +not break; on every service-refused and outright-refused door, clearing the flag |
| +lets the IDENTICAL call through, which is what proves the flag is the only |
| +variable; 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 |
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go| index d6760b9b0..e93fa55b7 100644 |
| --- a/internal/console/apidomains.go |
| +++ b/internal/console/apidomains.go |
| @@ -209,6 +209,38 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) { |
| if !ok { |
| return |
| } |
| + // The CREATE half of the provision-record lifecycle, reached through |
| + // this door rather than the instance one: the first-route branch below |
| + // writes a declared provision.Record (attachDomain, domainattach.go), |
| + // and a record is what transfers reconciler authority over the app's |
| + // route. `POST .../instances` already refuses a service for exactly |
| + // that; leaving this open would let a CI credential acquire the same |
| + // authority by taking the other door. |
| + // |
| + // SERVICE-ONLY, not outright, and the split is the one the flag draws |
| + // everywhere it can: a person attaching a domain to the control plane is |
| + // an ordinary operator act — the same class as writing its instance |
| + // record — while a CI credential doing it is the console's route being |
| + // claimed by something with no standing to claim it. Detach is the |
| + // asymmetric one (refused outright) because REMOVAL is destruction. |
| + // |
| + // Placed straight after domainsGate, which puts it after the family's |
| + // byte-identical 404 and before EVERY branch's write — the static branch |
| + // that delegates to the shared core as well as the alias branch below. |
| + // It does not disturb the neutrality ordering the alias branch's own |
| + // comment protects: that refusal is about which HOSTS exist, readable by |
| + // any caller, whereas this one fires only for a service that is already |
| + // a proven binding of this account and app, and tells it only what the |
| + // promote door would tell it anyway. |
| + // |
| + // The browser twin (handleAppDomainAttach, appdomains.go) needs no |
| + // counterpart: it authenticates a session, so its principal is always a |
| + // person and this refusal could never fire there. The shared core |
| + // takes an audit-actor STRING rather than a Principal, which is the same |
| + // fact stated in the types. |
| + if s.refuseServiceControlPlane(w, p, acct.ID, app, "attach a domain to it") { |
| + return |
| + } |
| principal := auditActor(p, acct) |
| var in struct { |
| Host string `json:"host"` |
diff --git a/internal/console/controlplane_test.go b/internal/console/controlplane_test.go| index 9e2123b0e..181587124 100644 |
| --- a/internal/console/controlplane_test.go |
| +++ b/internal/console/controlplane_test.go |
| @@ -1210,3 +1210,71 @@ func TestControlPlaneDomainDetachRefusalIsNotAnOracle(t *testing.T) { |
| assertNotFound(t, "non-member detach on a flagged app", canonicalNotFound(t), status, h, out) |
| claimOf(t, fx, host) |
| } |
| + |
| +// TestControlPlaneAppRefusesAServiceDomainAttach closes the CREATE half of |
| +// the provision-record lifecycle at the door that is not the instance one. |
| +// The first-route branch of `POST .../domains` writes a declared |
| +// provision.Record, which is the same authority transfer `POST .../instances` |
| +// refuses a service for — so a CI credential must not be able to acquire it |
| +// by taking the other door. |
| +// |
| +// Service-only, not outright: a PERSON attaching a domain to the control |
| +// plane is an ordinary operator act. Removal is the asymmetric one. |
| +// |
| +// Both service roles the route admits, and both request SHAPES — the alias |
| +// form and the `kind:"static"` first-route form — because the guard is |
| +// deliberately not conditional on which branch would have run. |
| +func TestControlPlaneAppRefusesAServiceDomainAttach(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if err := fx.st.SetAppControlPlane(OpsAccountID, "kass", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + u := fx.ts.URL + "/api/cli/apps/bab/kass/domains" |
| + const host = "docs.example.com" |
| + |
| + for _, role := range []svcrole.Role{svcrole.Publish, svcrole.Instance} { |
| + svc := serviceToken(t, fx.st, OpsAccountID, "ci-"+string(role), "kass", role) |
| + for what, body := range map[string]string{ |
| + "alias form": attachBody(host, "kass.carlosapp.dev"), |
| + "first-route form": `{"host":"` + host + `","kind":"static"}`, |
| + } { |
| + status, _, out := rawReq(t, http.MethodPost, u, svc, body) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("a SERVICE (%s) attaching a domain to the control plane, %s = %d %s, want 403", |
| + role, what, status, out) |
| + } |
| + if !strings.Contains(out, "a person must") { |
| + t.Fatalf("refusal %q does not say a person is the principal this needs", out) |
| + } |
| + } |
| + claimAbsent(t, fx, host) |
| + } |
| + |
| + // The half that must keep working: a person attaches. |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-cp-attach") |
| + if status, _, out := rawReq(t, http.MethodPost, u, token, |
| + attachBody(host, "kass.carlosapp.dev")); status != http.StatusOK { |
| + t.Fatalf("a PERSON attaching a domain to the control plane = %d %s, want 200", status, out) |
| + } |
| + claimOf(t, fx, host) |
| + |
| + // A non-member still meets the family's byte-identical 404, never the |
| + // refusal: the guard sits after domainsGate, so it is not an oracle for |
| + // which accounts hold a control-plane app. |
| + eve := cliToken(t, fx, "eve@keymail.dev", "code-cp-attach-eve") |
| + status, h, out := rawReq(t, http.MethodPost, u, eve, attachBody("eve.example.com", "kass.carlosapp.dev")) |
| + assertNotFound(t, "non-member attach on a flagged app", canonicalNotFound(t), status, h, out) |
| + claimAbsent(t, fx, "eve.example.com") |
| + |
| + // The control: with the flag cleared, the identical service call lands. |
| + // The flag is the only variable between these two outcomes. |
| + if err := fx.st.SetAppControlPlane(OpsAccountID, "kass", false); err != nil { |
| + t.Fatal(err) |
| + } |
| + svc := serviceToken(t, fx.st, OpsAccountID, "ci-cleared", "kass", svcrole.Publish) |
| + if status, _, out := rawReq(t, http.MethodPost, u, svc, |
| + attachBody("other.example.com", "kass.carlosapp.dev")); status != http.StatusOK { |
| + t.Fatalf("a SERVICE attaching once the flag is cleared = %d %s, want 200", status, out) |
| + } |
| + claimOf(t, fx, "other.example.com") |
| +} |