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>
5 files changed,
+261
−3
docs/superpowers/specs/2026-08-04-instance-provisioning-design.md+33 −0internal/console/attachhint_test.go+132 −0internal/console/server.go+79 −1internal/console/server_test.go+10 −1internal/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/<name></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/<name> <host></code>.</p> |
| +<p class="small">Creates channel <code>canary/<name></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's platform subdomain. The host comes live on its box's next provision pass.</p> |
| +{{else}}<pre><code>{{.AttachCmd}}</code></pre> |
| +<p class="small">The host comes live on its box's next provision pass.</p> |
| +{{end}} |
| {{end}} |
| <h2>Releases</h2> |