carlos / platform Public

instances: a first instance follows the app's pipeline entry, not stable (#372)

Creating an instance never read the app's pipeline. An omitted channel
was defaulted to the manifest's and then to a flat "stable", while every
app the console creates is born with a one-channel pipeline whose entry
is "edge" (release.DefaultPipelineChannels) and `carlos deploy` resolves
its own channel from that entry. So a brand-new app's FIRST instance
followed a channel nothing ever promotes to: the first deploy met
stable's passkey, and so did the obvious repair of moving it to edge —
a new app was production before anything had been promoted to it
(amadan carlos/platform#5, and the hello.bdn repair of 2026-08-22 that
took a raw bucket write).

The door now resolves an omitted channel: the manifest's (app policy,
unchanged and still first), else the channel this app's OTHER instances
follow, else its pipeline entry. The sibling rung is the rail that keeps
this out of production — tito mints a record per customer, and a new one
quietly landing on the entry channel would serve that customer the edge
build. canary/* siblings do not vote: a canary instance is made to
verify a build and deleted after.

Two cases refuse rather than guess, because a record's channel is chosen
once and this door cannot take it back: an unreadable pipeline is a 502,
not an absent pipeline defaulted through to "stable" (apipipeline.go's
own rule), and instances that follow several channels are a 422 naming
--channel — attachChannel's refusal, same words, same reason.

An explicit channel is untouched and reads no pipeline at all.


Claude-Session: https://claude.ai/code/session_01DqT65F1iMwc8NjVG9r8Tr7
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 29046890b5877ced51d2adfc5b85ed3babd3bfc1 parent f10d8e1
5 files changed, +364 −15
  • deploy.go +10 −6
  • instances_cli.go +8 −1
  • internal/console/apiinstances.go +80 −5
  • internal/console/apiinstances_test.go +6 −3
  • internal/console/apiinstanceschannel_test.go +260 −0
diff --git a/deploy.go b/deploy.go
index 0e40608ce..7172f33a9 100644
--- a/deploy.go
+++ b/deploy.go
@@ -794,12 +794,16 @@ var errNoStaticHost = errors.New("no static host")
// — and it answers with the domain the console pins to the account, so this
// client never has to know the deployment's apps domain to build the host.
//
-// Then the record, carrying the kind and — the rail that must not bend — an
-// EXPLICIT channel. An omitted one is defaulted by the door to the
-// manifest's, then to "stable", while a fresh app's pipeline entry is
-// "edge": that record would follow a channel nothing ever promotes to, and
-// no door can change a record's channel afterwards. Repairing exactly that
-// took a raw bucket write (hello.bdn, 2026-08-22).
+// Then the record, carrying the kind and — still explicit, though the door
+// no longer needs it to be — the channel this deploy resolved. The door used
+// to default an omitted channel to the manifest's and then to "stable" while
+// a fresh app's pipeline entry was "edge", so the record followed a channel
+// nothing ever promotes to; repairing one took a raw bucket write (hello.bdn,
+// 2026-08-22) and hitting it cost a first deploy a passkey (amadan #5). The
+// door now resolves the entry channel itself (instanceChannel). Passing it
+// stays right regardless: this caller has already resolved the channel it is
+// about to promote to, and a record minted on a different one would serve
+// something else.
func mintPlatformHost(ctx context.Context, sess *apiSession, acct apiAcct, app, channel string) (string, error) {
m, err := sess.instancesEnable(ctx, acct, app, nil, nil)
if err != nil {
diff --git a/instances_cli.go b/instances_cli.go
index 7096f883b..74df7630c 100644
--- a/instances_cli.go
+++ b/instances_cli.go
@@ -191,6 +191,13 @@ Signed-in only (carlos auth login): instance records are console-authorized
writes — the console checks the host against the app's manifest and the
box's reconcile pass materializes the route.
+create stamps a channel on the record, and prints the one it chose. Named
+with --channel it is yours; omitted, it is the app's own enable channel, else
+the channel this app's other instances already follow, else its pipeline
+entry — where "carlos deploy" lands, so a brand-new app's first instance is
+deployable without a passkey. Instances that follow SEVERAL channels are
+refused rather than guessed at: pass --channel.
+
set-channel moves ONE route onto a different release channel. The channel
lives in the instance record, which the box converges the route from every
pass — so this is the verb that changes it, not "carlos route --channel",
@@ -243,7 +250,7 @@ func runInstancesCreate(args []string) {
app := appFlag(fs, "app the instance belongs to")
host := fs.String("host", "", "instance host — one DNS label + \".\" + the app's instance domain")
region := fs.String("region", "", "region code for the record (subject to the manifest's allow-list)")
- channel := channelFlag(fs, "release channel the instance follows (default: the manifest's, then stable)")
+ channel := channelFlag(fs, "release channel the instance follows (default: the manifest's, then this app's other instances, then its pipeline entry)")
kind := fs.String("kind", "", "route class: instance (default) | service | static — a static record serves a file tree off the channel pointer and has no process")
addr := fs.String("addr", "", "comma-separated TCP upstream set, host:port each (e.g. 10.0.0.1:8090,10.0.0.2:8090); omitted means the platform's own default upstream")
healthPath := fs.String("health-path", "", "states the whole probe alongside --health-status: request path (default \"/\"); honoured for a single --addr too")
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go
index 1eeadc26e..f1c4b2daa 100644
--- a/internal/console/apiinstances.go
+++ b/internal/console/apiinstances.go
@@ -630,12 +630,12 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request)
}
channel := strings.TrimSpace(req.Channel)
if channel == "" {
- // The manifest's channel, then the documented default (§3.1). An
- // instance always names a channel: a record with an empty one would
- // materialize a route pinned to nothing.
- if channel = m.Channel; channel == "" {
- channel = "stable"
+ resolved, ref := s.instanceChannel(ctx, acct.Sqid, app, m)
+ if ref != nil {
+ jsonError(w, ref.Status, ref.Msg)
+ return
}
+ channel = resolved
}
if !release.ValidChannel(channel) {
jsonError(w, http.StatusBadRequest, "invalid channel name")
@@ -731,6 +731,81 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusCreated, rec)
}
+// instanceChannel answers "which channel is this record born on" for a
+// create that named none — amadan carlos/platform#5, where a brand-new app's
+// first instance landed on "stable" and the first deploy then met stable's
+// passkey, as did the obvious repair of moving it to edge.
+//
+// The rungs, in order, and why each one is above the next:
+//
+// manifest channel app policy, written at `carlos instances enable
+// -channel`. An app that declared one is not re-decided
+// here, in either direction — this is what keeps tito's
+// per-customer creates landing where every sibling is.
+// sibling records the channel this app's OTHER instances follow, when
+// they agree. A record per customer is the flagship's
+// shape, and a new one quietly landing on the pipeline
+// entry would serve that customer the edge build.
+// canary/* siblings do not vote: a canary instance is
+// made to verify a build and deleted after, so the
+// throwaway must not set policy for the real one.
+// pipeline entry where this app's releases actually land
+// (release.Pipeline.Entry), which is what `carlos
+// deploy` resolves its own channel from. An app with no
+// pipeline.json falls to the starter pipeline's entry,
+// which is also the legacy ladder's: nothing is promoted
+// ONTO edge — merging is what puts a build there.
+//
+// Two cases refuse rather than guess, because a record's channel is chosen
+// once and this door cannot take it back:
+//
+// an unreadable pipeline is NOT an absent one (apipipeline.go's rule) — a
+// bucket hiccup must not mint a production route by falling through, and
+// instances that disagree have no default worth inventing (attachChannel's
+// refusal, same words, same reason).
+//
+// A caller who named a channel never reaches here: an explicit channel is
+// the caller's decision, is shape-checked only, and costs no bucket reads.
+func (s *Server) instanceChannel(ctx context.Context, sqid, app string, m *provision.Manifest) (string, *doorRefusal) {
+ if m.Channel != "" {
+ return m.Channel, nil
+ }
+ recs, _, err := provision.ListRecords(ctx, s.cfg.Releases, sqid, app, instanceListMax)
+ if err != nil {
+ log.Printf("console: api instances create %s/%s: list siblings: %v", sqid, app, err)
+ return "", &doorRefusal{http.StatusBadGateway,
+ "could not read this app's instances — try again, or pass -channel"}
+ }
+ var found []string
+ for _, rec := range recs {
+ if rung, ok := release.Rung(rec.Channel); rec.Channel == "" || (ok && rung == release.RungCanary) {
+ continue
+ }
+ if !slices.Contains(found, rec.Channel) {
+ found = append(found, rec.Channel)
+ }
+ }
+ slices.Sort(found)
+ if len(found) > 1 {
+ return "", &doorRefusal{http.StatusUnprocessableEntity,
+ "this app's instances follow several channels (" + strings.Join(found, ", ") +
+ ") — pass -channel to say which one this instance follows"}
+ }
+ if len(found) == 1 {
+ return found[0], nil
+ }
+ p, err := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, sqid, app)
+ if err != nil {
+ log.Printf("console: api instances create %s/%s: pipeline read: %v", sqid, app, err)
+ return "", &doorRefusal{http.StatusBadGateway,
+ "could not read this app's pipeline — try again, or pass -channel"}
+ }
+ if p != nil {
+ return p.Entry(), nil
+ }
+ return release.DefaultPipelineChannels()[0].Name, nil
+}
+
// handleAPIInstanceUpdate repoints a live instance's UPSTREAM SET, probe
// config and (Task 3, multi-region-steering plan) DNS STEERING opt-in — the
// fields of a record that are meant to change while the instance keeps
diff --git a/internal/console/apiinstances_test.go b/internal/console/apiinstances_test.go
index 3edc08607..78e759078 100644
--- a/internal/console/apiinstances_test.go
+++ b/internal/console/apiinstances_test.go
@@ -538,9 +538,12 @@ func TestAPIInstanceCreateEmptyRegionAllowList(t *testing.T) {
if err := json.Unmarshal([]byte(out), &rec); err != nil {
t.Fatal(err)
}
- // No manifest channel either: stable is the documented default.
- if rec.Channel != "stable" {
- t.Fatalf("channel = %q, want the stable default", rec.Channel)
+ // No manifest channel either, and this app has no pipeline, so the
+ // record follows the entry channel every app's releases land on.
+ // apiinstanceschannel_test.go is where that resolution is pinned; here
+ // it is only asserted so an open allow-list cannot quietly change it.
+ if rec.Channel != "edge" {
+ t.Fatalf("channel = %q, want the entry-channel default", rec.Channel)
}
if status, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"beta.go.tito.io"}`); status != http.StatusCreated {
t.Fatalf("create with no region = %d %s, want 201", status, out)
diff --git a/internal/console/apiinstanceschannel_test.go b/internal/console/apiinstanceschannel_test.go
new file mode 100644
index 000000000..bb79b4588
--- /dev/null
+++ b/internal/console/apiinstanceschannel_test.go
@@ -0,0 +1,260 @@
+package console
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// The channel an instance record is BORN on, when the caller named none.
+//
+// This is the resolution amadan carlos/platform#5 is about: a brand-new app
+// gets a one-channel pipeline whose entry is "edge"
+// (release.DefaultPipelineChannels), `carlos deploy` resolves its own
+// channel from that entry (deployTargets), and this door used to answer an
+// omitted channel with the manifest's, then a flat "stable". So the first
+// instance of a new app followed a channel nothing ever promotes to, and the
+// first deploy hit stable's passkey wall with no way past it that did not
+// also need a passkey.
+//
+// The rails these tests pin, in the order the door applies them:
+//
+// explicit channel never reads the pipeline at all
+// manifest channel app policy, set at enable, still wins
+// sibling records this app's other instances, when they agree
+// pipeline entry where this app's releases actually land
+//
+// and, for the two cases where guessing would be worse than refusing: an
+// unreadable pipeline is a 502, and siblings that disagree are a 422 naming
+// the flag.
+
+// unchanneledManifest is titoManifest without the channel — an app whose
+// enable never named one, which is what the member-facing enable door
+// writes when `carlos instances enable` is run without -channel.
+func unchanneledManifest() provision.Manifest {
+ m := titoManifest()
+ m.Channel = ""
+ return m
+}
+
+// pipelineNamed is setPipeline (pendingflow_test.go) taking bare channel
+// names — these tests care about the ORDER of the list, never a channel's
+// bake or rules.
+func pipelineNamed(t *testing.T, fx *consoleFixture, app string, names ...string) {
+ t.Helper()
+ chans := make([]release.PipelineChannel, len(names))
+ for i, n := range names {
+ chans[i] = release.PipelineChannel{Name: n}
+ }
+ setPipeline(t, fx, app, chans...)
+}
+
+// sibling writes one existing instance record for the app, straight into the
+// bucket — an instance that already exists when the next create arrives.
+func sibling(t *testing.T, fx *consoleFixture, app, host, channel string) {
+ t.Helper()
+ if err := provision.CreateRecord(context.Background(), fx.rel, provision.Record{
+ Schema: 1, Host: host, Slug: strings.SplitN(host, ".", 2)[0],
+ App: app, Account: release.OpsAccount, Region: "ie", Channel: channel,
+ CreatedAt: time.Now().UTC(), CreatedBy: "paul@keymail.dev",
+ }); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// createdChannel POSTs a create with no channel field and returns the
+// channel the door stamped on the STORED record — the bucket copy is what
+// the reconcile pass mints the route from, so it is the one that matters.
+func createdChannel(t *testing.T, fx *consoleFixture, token, app, host string) string {
+ t.Helper()
+ status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", app), token,
+ `{"host":"`+host+`","region":"ie"}`)
+ if status != http.StatusCreated {
+ t.Fatalf("create = %d %s, want 201", status, out)
+ }
+ rec, err := provision.GetRecord(context.Background(), fx.rel, release.OpsAccount, app, host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return rec.Channel
+}
+
+// TestAPIInstanceCreateDefaultsToPipelineEntry is the bug itself: a fresh
+// app, its pipeline the one-channel starter every app is created with, and
+// nobody named a channel. The record must follow "edge" — where this app's
+// releases land — not "stable", which nothing promotes to and which no
+// member can reach without a passkey.
+func TestAPIInstanceCreateDefaultsToPipelineEntry(t *testing.T) {
+ fx, member := instanceFixture(t, "code-inst-chan-entry")
+ enableInstances(t, fx, "titogo", unchanneledManifest())
+ pipelineNamed(t, fx, "titogo", "edge")
+
+ if got := createdChannel(t, fx, member, "titogo", "acme.go.tito.io"); got != "edge" {
+ t.Fatalf("stored channel = %q, want edge — the app's pipeline entry", got)
+ }
+}
+
+// TestAPIInstanceCreateRenamedPipelineEntry: the entry is whatever the app's
+// own pipeline says it is, not the word "edge". An account that declared
+// [preview, live] gets preview — the reason this reads the pipeline instead
+// of hard-coding the ladder's entry rung.
+func TestAPIInstanceCreateRenamedPipelineEntry(t *testing.T) {
+ fx, member := instanceFixture(t, "code-inst-chan-renamed")
+ enableInstances(t, fx, "titogo", unchanneledManifest())
+ pipelineNamed(t, fx, "titogo", "preview", "live")
+
+ if got := createdChannel(t, fx, member, "titogo", "acme.go.tito.io"); got != "preview" {
+ t.Fatalf("stored channel = %q, want preview — this app's own entry channel", got)
+ }
+}
+
+// TestAPIInstanceCreateFollowsItsSiblings is the rail that keeps this change
+// out of production: an app whose instances already run on one channel mints
+// its NEXT instance on that same channel. Tito adds a record per customer;
+// a new one silently landing on the pipeline entry would serve that customer
+// the edge build.
+func TestAPIInstanceCreateFollowsItsSiblings(t *testing.T) {
+ fx, member := instanceFixture(t, "code-inst-chan-siblings")
+ enableInstances(t, fx, "titogo", unchanneledManifest())
+ pipelineNamed(t, fx, "titogo", "edge", "beta", "stable")
+ sibling(t, fx, "titogo", "one.go.tito.io", "beta")
+ sibling(t, fx, "titogo", "two.go.tito.io", "beta")
+
+ if got := createdChannel(t, fx, member, "titogo", "acme.go.tito.io"); got != "beta" {
+ t.Fatalf("stored channel = %q, want beta — the channel this app's instances are on", got)
+ }
+}
+
+// TestAPIInstanceCreateIgnoresCanarySiblings: canary/* instances are the
+// throwaway kind — created to verify a build, deleted afterwards (the very
+// thing #5's reporter did) — so they do not get to decide what a real
+// instance follows. With only canary siblings the door falls through to the
+// pipeline entry.
+func TestAPIInstanceCreateIgnoresCanarySiblings(t *testing.T) {
+ fx, member := instanceFixture(t, "code-inst-chan-canary")
+ enableInstances(t, fx, "titogo", unchanneledManifest())
+ pipelineNamed(t, fx, "titogo", "edge")
+ sibling(t, fx, "titogo", "check.go.tito.io", "canary/boot-check")
+
+ if got := createdChannel(t, fx, member, "titogo", "acme.go.tito.io"); got != "edge" {
+ t.Fatalf("stored channel = %q, want edge — a canary sibling is not a policy", got)
+ }
+}
+
+// TestAPIInstanceCreateAmbiguousSiblingsRefuse: an app whose instances
+// follow SEVERAL channels has no default worth guessing — picking either one
+// silently decides which build a new host serves. The refusal is
+// attachChannel's, in the same words and for the same reason: say which.
+func TestAPIInstanceCreateAmbiguousSiblingsRefuse(t *testing.T) {
+ fx, member := instanceFixture(t, "code-inst-chan-ambiguous")
+ enableInstances(t, fx, "titogo", unchanneledManifest())
+ pipelineNamed(t, fx, "titogo", "edge", "beta", "stable")
+ sibling(t, fx, "titogo", "one.go.tito.io", "edge")
+ sibling(t, fx, "titogo", "two.go.tito.io", "stable")
+
+ status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member,
+ `{"host":"acme.go.tito.io","region":"ie"}`)
+ if status != http.StatusUnprocessableEntity {
+ t.Fatalf("create with disagreeing siblings = %d %s, want 422", status, out)
+ }
+ if !strings.Contains(out, "edge") || !strings.Contains(out, "stable") {
+ t.Fatalf("refusal %s does not name the channels found", out)
+ }
+ if !strings.Contains(out, "channel") {
+ t.Fatalf("refusal %s does not name what to pass", out)
+ }
+ // A refused create writes nothing.
+ if _, err := provision.GetRecord(context.Background(), fx.rel, release.OpsAccount,
+ "titogo", "acme.go.tito.io"); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("a refused create left a record behind: %v", err)
+ }
+}
+
+// TestAPIInstanceCreateLegacyAppDefaultsToEdge: an app from before pipelines
+// v2 has no pipeline.json, and the ladder it falls back to has the same
+// entry — nothing is promoted ONTO edge; merging is what puts a build there
+// (release/ladder.go). So a first instance lands there too.
+func TestAPIInstanceCreateLegacyAppDefaultsToEdge(t *testing.T) {
+ fx, member := instanceFixture(t, "code-inst-chan-legacy")
+ enableInstances(t, fx, "titogo", unchanneledManifest())
+
+ if got := createdChannel(t, fx, member, "titogo", "acme.go.tito.io"); got != "edge" {
+ t.Fatalf("stored channel = %q, want edge — the ladder's entry rung", got)
+ }
+}
+
+// TestAPIInstanceCreateUnreadablePipelineRefuses: an unreadable pipeline is
+// not an absent one (apipipeline.go's stated rule). Defaulting through it to
+// "stable" would mint a production route because the bucket hiccuped, and a
+// record's channel is not something this door can take back.
+func TestAPIInstanceCreateUnreadablePipelineRefuses(t *testing.T) {
+ fx, member := instanceFixture(t, "code-inst-chan-unreadable")
+ enableInstances(t, fx, "titogo", unchanneledManifest())
+ if _, err := fx.rel.Put(context.Background(),
+ release.PipelineKey(release.OpsAccount, "titogo"), []byte("not json"), ""); err != nil {
+ t.Fatal(err)
+ }
+
+ status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member,
+ `{"host":"acme.go.tito.io","region":"ie"}`)
+ if status != http.StatusBadGateway {
+ t.Fatalf("create against an unreadable pipeline = %d %s, want 502", status, out)
+ }
+ if !strings.Contains(out, "channel") {
+ t.Fatalf("refusal %s does not tell the caller they can name a channel", out)
+ }
+ if _, err := provision.GetRecord(context.Background(), fx.rel, release.OpsAccount,
+ "titogo", "acme.go.tito.io"); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("a refused create left a record behind: %v", err)
+ }
+}
+
+// TestAPIInstanceCreateExplicitChannelNeverReadsThePipeline: a named channel
+// is the caller's decision and is stamped as given — even when the pipeline
+// object is unreadable and even when it names a channel the pipeline does
+// not list. This door validates channel SHAPE, never membership: canary/*
+// lives outside every pipeline, and a channel a member is about to ship to
+// is a legitimate target.
+func TestAPIInstanceCreateExplicitChannelNeverReadsThePipeline(t *testing.T) {
+ fx, member := instanceFixture(t, "code-inst-chan-explicit")
+ enableInstances(t, fx, "titogo", unchanneledManifest())
+ if _, err := fx.rel.Put(context.Background(),
+ release.PipelineKey(release.OpsAccount, "titogo"), []byte("not json"), ""); err != nil {
+ t.Fatal(err)
+ }
+
+ status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member,
+ `{"host":"acme.go.tito.io","region":"ie","channel":"canary/boot-check"}`)
+ if status != http.StatusCreated {
+ t.Fatalf("explicit-channel create = %d %s, want 201", status, out)
+ }
+ rec, err := provision.GetRecord(context.Background(), fx.rel, release.OpsAccount,
+ "titogo", "acme.go.tito.io")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rec.Channel != "canary/boot-check" {
+ t.Fatalf("stored channel = %q, want the requested canary/boot-check", rec.Channel)
+ }
+}
+
+// TestAPIInstanceCreateManifestChannelStillWins pins the rung ABOVE the
+// pipeline: `carlos instances enable -channel stable` is app policy, and an
+// app that carries one is not re-decided by its pipeline. This is what keeps
+// tito's per-customer creates landing on stable.
+func TestAPIInstanceCreateManifestChannelStillWins(t *testing.T) {
+ fx, member := instanceFixture(t, "code-inst-chan-manifest")
+ enableInstances(t, fx, "titogo", titoManifest()) // Channel: "stable"
+ pipelineNamed(t, fx, "titogo", "edge")
+
+ if got := createdChannel(t, fx, member, "titogo", "acme.go.tito.io"); got != "stable" {
+ t.Fatalf("stored channel = %q, want the manifest's stable", got)
+ }
+}