carlos / platform Public

console: send the reader at the API-backed verb, not box-local carlos add (#99)

The canary form on an app's releases tab and the post-create flash both
printed

  carlos add -app <a> -channel canary/<n> <host>

into a browser. That verb opens THIS box's registry on local disk (cli.go's
defaultRegistry), so it cannot run from wherever a dashboard is being read —
the same box-local-only gap CLAUDE.md names for `carlos instances`. An
operator followed it from a laptop and met flag validation instead of a route.

Nothing was missing underneath. POST .../instances has always taken an
explicit channel (spec §7) and provision.Reconcile's mint already carries
rec.Channel onto the route, so a canary host has been a supported API-backed
create since §12a. Only the advice was wrong.

Both doors now render `carlos instances create -app <a> -host <name>.<domain>
-channel canary/<n>`, computed from the app's own instances manifest, and lead
with `carlos instances enable` when that manifest is PROVEN absent. The page
half is computed in handleAppReleases, not the shared appPage chrome: the
canary form lives on that tab and nowhere else, so neither does the read.

Proven is load-bearing, and is why instanceDomainFor answers three ways rather
than two. The enable door writes guarded (PutManifestIfMatch) and answers 409
over a standing manifest, so collapsing "could not read the bucket" into
"instances are off" would put a doomed enable command in front of an operator
during exactly the hiccup that produced it. Unreadable renders the neutral
form; only os.ErrNotExist leads with enable.

carlos add stays correct and documented for an operator ON the box — it
provisions shapes records do not model (spec §9). It is simply not what a
console page should hand its reader.

Tests pin which verb the console names, including that `carlos add` is absent
from both doors — that is the regression, not the wording. attachCommands
covers the five host-shape cases; instanceDomainFor's three states use the
package's existing unreadableStore.

Spec §12b records it, since §9 could otherwise read as if the box-local hint
were deliberate. Recorded not built: the minted route shape is fixed
(Hibernate true, exec backing), so a record cannot ask for -backing unit.

go build/vet/test ./... clean, 21 packages; gofmt clean on touched files.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
James Adam 82dbca9dac93b4cd599d620c7bf39f67765a1a10 parent 6a52ae0
5 files changed, +261 −3
  • docs/superpowers/specs/2026-08-04-instance-provisioning-design.md +33 −0
  • internal/console/attachhint_test.go +132 −0
  • internal/console/server.go +79 −1
  • internal/console/server_test.go +10 −1
  • internal/console/templates/app_releases.html +7 −1
diff --git a/docs/superpowers/specs/2026-08-04-instance-provisioning-design.md b/docs/superpowers/specs/2026-08-04-instance-provisioning-design.md
index d412fb808..55fe15ed2 100644
--- a/docs/superpowers/specs/2026-08-04-instance-provisioning-design.md
+++ b/docs/superpowers/specs/2026-08-04-instance-provisioning-design.md
@@ -422,6 +422,39 @@ mint the app's apex onto that one customer's socket — pre-existing
behaviour, now reachable by members, worth its own slice if platform-pinned
multi-tenant apps become a real shape.
+## 12b. Addendum (2026-08-07): the console must advertise the API-backed verb
+
+§9's "`runAdd` stays a box-side, registry-direct verb" is a statement about
+where that verb *lives*, not about what the **console** should tell a reader
+to run. The console had been doing both: the canary form on the app's releases
+tab and the post-create flash each printed `carlos add -app <a> -channel canary/<n>
+<host>` into a browser. That command opens the box's registry on local disk
+(`cli.go`'s `defaultRegistry`), so it cannot work from wherever a dashboard is
+being read — the same box-local-only gap CLAUDE.md names for `carlos
+instances`. An operator followed it from a laptop and hit the flag validation
+instead of a route.
+
+Nothing new was needed underneath: `POST .../instances` already takes an
+explicit `channel` (§7), and `provision.Reconcile`'s mint already carries
+`rec.Channel` onto the route, so a canary host has been a supported
+API-backed create since §12a. Both doors now render `carlos instances create
+-app <a> -host <name>.<domain> -channel canary/<n>`, computed from the app's
+own manifest, and lead with `carlos instances enable` when the manifest is
+**proven** absent, computed in the releases tab handler rather than the shared
+app chrome — the canary form lives on that tab and nowhere else. Proven is
+load-bearing: the enable door writes guarded and
+409s over a standing manifest, so an unreadable bucket renders the neutral
+form rather than sending someone at a refusal.
+
+`carlos add` remains correct and documented for an operator on the box — it
+provisions shapes records do not model (§9). It is simply not what a console
+page should hand to its reader.
+
+Recorded, not built: the minted route shape is fixed (`Hibernate: true`, exec
+backing, `DefaultSocket`) — an instance record cannot ask for `-backing unit`,
+so a deployment that needs unit tenants still reaches for `carlos add` on the
+box. Worth its own slice if unit-backed instances become a real shape.
+
## 13. Testing
- `internal/provision`: record/manifest validation matrices, key parsing
diff --git a/internal/console/attachhint_test.go b/internal/console/attachhint_test.go
new file mode 100644
index 000000000..a64d3b921
--- /dev/null
+++ b/internal/console/attachhint_test.go
@@ -0,0 +1,132 @@
+package console
+
+// The "give this canary a URL" advice the app page and the post-create flash
+// both render. The property under test is not the wording — it is WHICH verb
+// the console sends its reader at. `carlos add` writes the box's registry on
+// local disk (cli.go's defaultRegistry), so a browser session anywhere else
+// cannot run it; `carlos instances create` is the API-backed door a login
+// credential can drive, and provision.Reconcile carries its channel onto the
+// minted route. A dashboard advertising the box-local verb is the bug this
+// file pins shut (2026-08-07).
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+func TestAttachCommands(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ domain string
+ appsDomain, sqid string
+ absent bool
+ wantCreate string
+ wantEnable string
+ }{{
+ // Enabled: the manifest's own domain is where hosts live, whether it
+ // is the platform pin or a custom domain, and no enable step is owed.
+ name: "enabled platform domain", domain: "bab.oncarlos.test",
+ appsDomain: "oncarlos.test", sqid: "bab",
+ wantCreate: "carlos instances create -app echo -host <name>.bab.oncarlos.test -channel canary/rc",
+ }, {
+ name: "enabled custom domain", domain: "go.tito.io",
+ appsDomain: "oncarlos.test", sqid: "bab",
+ wantCreate: "carlos instances create -app echo -host <name>.go.tito.io -channel canary/rc",
+ }, {
+ // Proven absent: lead with enable, and name the host shape the enable
+ // door will pin — the account's own platform subdomain.
+ name: "absent with apps domain", absent: true,
+ appsDomain: "oncarlos.test", sqid: "bab",
+ wantCreate: "carlos instances create -app echo -host <name>.bab.oncarlos.test -channel canary/rc",
+ wantEnable: "carlos instances enable -app echo",
+ }, {
+ // Absent and the deployment has no platform apps domain: the enable
+ // door itself refuses (503), so there is no host shape to promise.
+ name: "absent without apps domain", absent: true, sqid: "bab",
+ wantCreate: "carlos instances create -app echo -host <host> -channel canary/rc",
+ wantEnable: "carlos instances enable -app echo",
+ }, {
+ // Unreadable manifest: neither enabled nor proven absent. The enable
+ // step must NOT appear — that door is guarded and answers 409 over a
+ // standing manifest, so a transient bucket error must not send an
+ // operator at a refusal.
+ name: "unreadable", appsDomain: "oncarlos.test", sqid: "bab",
+ wantCreate: "carlos instances create -app echo -host <host> -channel canary/rc",
+ }} {
+ t.Run(tc.name, func(t *testing.T) {
+ create, enable := attachCommands("echo", "canary/rc", tc.domain, tc.appsDomain, tc.sqid, tc.absent)
+ if create != tc.wantCreate {
+ t.Errorf("create = %q, want %q", create, tc.wantCreate)
+ }
+ if enable != tc.wantEnable {
+ t.Errorf("enable = %q, want %q", enable, tc.wantEnable)
+ }
+ if strings.Contains(create, "carlos add") || strings.Contains(enable, "carlos add") {
+ t.Errorf("advice names the box-local verb: %q / %q", create, enable)
+ }
+ })
+ }
+}
+
+// TestInstanceDomainForStates pins the three-way answer, because collapsing
+// "could not read" into "absent" is exactly what would put a doomed
+// `carlos instances enable` in front of an operator during a bucket hiccup.
+func TestInstanceDomainForStates(t *testing.T) {
+ fx := newConsoleFixture(t)
+ ctx := context.Background()
+
+ // No manifest written for the fixture's app: proven absent.
+ if domain, absent := fx.srv.instanceDomainFor(ctx, "bab", "kass"); domain != "" || !absent {
+ t.Fatalf("no manifest: domain=%q absent=%v, want \"\" true", domain, absent)
+ }
+
+ // A manifest present: its domain, and not absent.
+ m := provision.Manifest{Schema: 1, App: "kass", Domain: "bab.oncarlos.test", Channel: "stable"}
+ if err := provision.PutManifest(ctx, fx.rel, "bab", "kass", m); err != nil {
+ t.Fatal(err)
+ }
+ if domain, absent := fx.srv.instanceDomainFor(ctx, "bab", "kass"); domain != "bab.oncarlos.test" || absent {
+ t.Fatalf("with manifest: domain=%q absent=%v, want bab.oncarlos.test false", domain, absent)
+ }
+
+ // A read that fails for any reason other than not-found is NOT absent.
+ fx.srv.cfg.Releases = unreadableStore{fx.rel}
+ if domain, absent := fx.srv.instanceDomainFor(ctx, "bab", "kass"); domain != "" || absent {
+ t.Fatalf("unreadable: domain=%q absent=%v, want \"\" false", domain, absent)
+ }
+}
+
+// TestAppPageNamesAPIBackedAttach is the regression at the level the operator
+// met it: the app page's canary form, rendered, must hand back a command the
+// reader can run from where they are.
+func TestAppPageNamesAPIBackedAttach(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.AppsDomain = "oncarlos.test"
+ if err := provision.PutManifest(context.Background(), fx.rel, "bab", "kass",
+ provision.Manifest{Schema: 1, App: "kass", Domain: "bab.oncarlos.test", Channel: "stable"}); err != nil {
+ t.Fatal(err)
+ }
+ c := signInPaul(t, fx)
+ // The releases tab: the canary form lives there since the console's tab
+ // split, and so does the advice beside it.
+ _, _, body := get(t, c, fx.u("/apps/kass/releases"))
+
+ // html/template escapes the <name> placeholder, so assert around it.
+ for _, want := range []string{
+ "carlos instances create -app kass -host ", "bab.oncarlos.test -channel canary/",
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("/apps/kass/releases missing %q in:\n%s", want, body)
+ }
+ }
+ // Enabled already: no enable step, and never the box-local verb.
+ if strings.Contains(body, "carlos instances enable") {
+ t.Error("app page offers `instances enable` for an app that already has a manifest")
+ }
+ if strings.Contains(body, "carlos add ") {
+ t.Error("app page still sends the reader at box-local `carlos add`")
+ }
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index c31e827de..4158735fa 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -23,6 +23,7 @@ import (
"time"
"github.com/carlosframework/platform/internal/domains"
+ "github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/status"
)
@@ -346,6 +347,14 @@ type page struct {
Events []Event // app page: recent console actions, newest first
CanPromote bool // a signing key is configured on this host
+ // App page: how a canary channel gets a URL, rendered as commands the
+ // reader can actually run from where they are reading. Computed
+ // server-side (attachCommands) because the host shape comes from the
+ // app's instances manifest. AttachEnable is non-empty only when
+ // instances are PROVEN off, so the copy leads with the enable step.
+ AttachCmd string
+ AttachEnable string
+
// The account chrome every /a/{sqid}/… page carries.
Account *Account // the account being viewed
Role string // the viewer's role in it: owner | member
@@ -1361,6 +1370,14 @@ func (s *Server) handleAppReleases(w http.ResponseWriter, r *http.Request) {
p.FlashErr = "Could not list this app's releases."
}
p.Versions = versions
+ // The canary form's "how do I give this a URL" copy — this tab's data, not
+ // shared chrome: the canary form lives here and nowhere else. Bounded and
+ // best-effort like every other store read behind an app page: an unreadable
+ // manifest costs the concrete host shape, never the page.
+ mctx, mcancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
+ domain, absent := s.instanceDomainFor(mctx, acct.Sqid, p.AppName)
+ mcancel()
+ p.AttachCmd, p.AttachEnable = attachCommands(p.AppName, "canary/<name>", domain, s.cfg.AppsDomain, acct.Sqid, absent)
s.render(w, http.StatusOK, "app_releases", p)
}
@@ -2008,7 +2025,68 @@ func (s *Server) handleNewCanary(w http.ResponseWriter, r *http.Request) {
back("ok", "Created canary "+name+" — live at https://"+hosts[0]+" serving "+version+" (canaries never bake).")
return
}
- back("ok", "Created canary "+name+" — "+channel+" serves "+version+" the moment a host follows it, no bake. Attach one: carlos add -app "+app+" -channel "+channel+" <host>.")
+ domain, absent := s.instanceDomainFor(ctx, acct.Sqid, app)
+ create, enable := attachCommands(app, channel, domain, s.cfg.AppsDomain, acct.Sqid, absent)
+ if enable != "" {
+ create = enable + " && " + create
+ }
+ back("ok", "Created canary "+name+" — "+channel+" serves "+version+" the moment a host follows it, no bake. Attach one: "+create)
+}
+
+// instanceDomainFor answers "where do this app's instance hosts live?" — the
+// app's instances-manifest domain when one exists, and whether its absence is
+// PROVEN. Both doors that tell a reader how to give a channel a URL ask this,
+// so both name the same command.
+//
+// The three-way answer is load-bearing: a manifest that merely could not be
+// READ is not "instances are off". The enable door writes guarded
+// (PutManifestIfMatch) and answers 409 over a standing manifest under another
+// domain, so telling someone to run `carlos instances enable` on the strength
+// of a transient bucket error sends them at a door that will refuse them.
+// Unreadable therefore renders the neutral form, never the enable step.
+func (s *Server) instanceDomainFor(ctx context.Context, sqid, app string) (domain string, absent bool) {
+ m, err := provision.GetManifest(ctx, s.cfg.Releases, sqid, app)
+ switch {
+ case err == nil:
+ return m.Domain, false
+ case errors.Is(err, os.ErrNotExist):
+ return "", true
+ default:
+ log.Printf("console: instances manifest %s/%s: %v", sqid, app, err)
+ return "", false
+ }
+}
+
+// attachCommands renders the "give this channel a URL" advice for one app:
+// the create command, plus the enable command that has to precede it when
+// instances are proven off ("" otherwise).
+//
+// It names `carlos instances create`, deliberately NOT `carlos add`. Both
+// place a route, but `carlos add` opens THIS box's registry on local disk
+// (cli.go's defaultRegistry) — it cannot be run from wherever a dashboard is
+// being read, and an operator who followed that hint from a browser landed on
+// a box-local verb that could not work for them (2026-08-07). The API-backed
+// path is the one a login credential can drive: spec §12a, and the record it
+// writes materializes as a route on the box's next provision pass, carrying
+// this channel through (provision.Reconcile's mint).
+func attachCommands(app, channel, domain, appsDomain, sqid string, absent bool) (create, enable string) {
+ // An instance host is exactly one label under the manifest's domain
+ // (provision.ValidHost), so the placeholder stands in for the label only.
+ host := "<host>"
+ switch {
+ case domain != "":
+ host = "<name>." + domain
+ case absent && appsDomain != "":
+ // Not enabled yet: the member-facing enable door pins the manifest to
+ // the account's own platform subdomain (§12a), so that is where the
+ // host will have to live once they run it.
+ host = "<name>." + sqid + "." + appsDomain
+ }
+ create = "carlos instances create -app " + app + " -host " + host + " -channel " + channel
+ if absent {
+ enable = "carlos instances enable -app " + app
+ }
+ return create, enable
}
// handleRetireCanary removes a canary's channel pointer. Shipped releases
diff --git a/internal/console/server_test.go b/internal/console/server_test.go
index 00a93b3d9..3526a6f9d 100644
--- a/internal/console/server_test.go
+++ b/internal/console/server_test.go
@@ -905,11 +905,20 @@ func TestCanaryFlow(t *testing.T) {
t.Fatalf("canary/rc pointer = %+v, want v1", p)
}
_, _, body = get(t, c, fx.u("/apps/kass"))
- for _, want := range []string{"Created canary rc", "no bake", "carlos add -app kass -channel canary/rc", "canary/rc"} {
+ // The flash names the API-backed verb. `carlos add` is box-local (it opens
+ // the box's registry on local disk), so a dashboard must never send its
+ // reader there — the whole point of the instances API path.
+ for _, want := range []string{
+ "Created canary rc", "no bake", "canary/rc",
+ "carlos instances enable -app kass", "carlos instances create -app kass", "-channel canary/rc",
+ } {
if !strings.Contains(body, want) {
t.Fatalf("post-create page missing %q in:\n%s", want, body)
}
}
+ if strings.Contains(body, "carlos add ") {
+ t.Errorf("post-create page still sends the reader at box-local `carlos add`:\n%s", body)
+ }
// New means new: the existing canary refuses re-creation.
postForm(t, c, fx.u("/apps/kass/canary"),
diff --git a/internal/console/templates/app_releases.html b/internal/console/templates/app_releases.html
index 27dd4c509..f2b8eec76 100644
--- a/internal/console/templates/app_releases.html
+++ b/internal/console/templates/app_releases.html
@@ -85,7 +85,13 @@
</select>
<button type="submit">Create canary</button>
</form>
-<p class="small">Creates channel <code>canary/&lt;name&gt;</code> pointing at the chosen release — serving immediately, no bake. It gets its URL from a host that follows the channel: <code>carlos add -app {{.Detail.App}} -channel canary/&lt;name&gt; &lt;host&gt;</code>.</p>
+<p class="small">Creates channel <code>canary/&lt;name&gt;</code> pointing at the chosen release — serving immediately, no bake. It gets its URL from a host that follows the channel:</p>
+{{if .AttachEnable}}<pre><code>{{.AttachEnable}}
+{{.AttachCmd}}</code></pre>
+<p class="small">This app has no instances manifest yet, so the first line enables one under this account&#39;s platform subdomain. The host comes live on its box&#39;s next provision pass.</p>
+{{else}}<pre><code>{{.AttachCmd}}</code></pre>
+<p class="small">The host comes live on its box&#39;s next provision pass.</p>
+{{end}}
{{end}}
<h2>Releases</h2>