carlos / platform Public

console: app placement is a store fact — create carries it

Controller ruling on Task 8's hole: an app created moments ago has no
routes, so `carlos apps create -place fleet/<f>` would be a silent
no-op under the old route-derived placement. Placement's source of
truth moves to the console store:

- apps table gains an additive `placement` column (default "" —
  identical to "never placed" under the old reading); migrateAppsKey's
  fresh-DB rebuild path carries it through too.
- Store.SetAppPlacement / Store.AppPlacement are the new accessors.
- handleAPIPlace writes the store BEFORE stamping routes/records
  (clearing sets "").
- handleAPICreateApp accepts an optional "place" field, validated with
  the exact same grammar + fleet-ownership checks handleAPIPlace
  applies to its own target, refusing before the app is ever created;
  claimApp threads an already-validated placement through to a
  best-effort store write after the insert (mirrors
  ensureAccountManifest's shape — the claim itself is never undone by
  this write failing).
- appPlacement (fleets.go) is now a *Server method: it consults the
  store first, falling back to the old route-derived walk only for an
  app placed before this column existed.

Tests: store round trip, create-with-place (happy, invalid shape 400,
foreign fleet 404 — nothing created on either refusal), instance-create
inheriting a route-less placed app's target, and the store-empty
route-fallback path. Full gate green (18/18 packages), gofmt clean.
Paul Campbell cfe8fb20318e9716ebf0e2fa60e20bfe47658d28 parent 1c30c46
9 files changed, +362 −32
  • internal/console/apicli.go +41 −2
  • internal/console/apicli_test.go +82 −0
  • internal/console/apiinstances.go +1 −1
  • internal/console/apiinstances_test.go +58 −0
  • internal/console/fleets.go +39 −22
  • internal/console/fleets_test.go +34 −0
  • internal/console/server.go +25 −2
  • internal/console/store.go +48 −5
  • internal/console/store_test.go +34 −0
diff --git a/internal/console/apicli.go b/internal/console/apicli.go
index 7afd4bb13..13529e3c7 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -25,6 +25,7 @@ import (
"time"
"github.com/carlosframework/platform/internal/caps"
+ "github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/version"
)
@@ -147,6 +148,16 @@ func (s *Server) apiAccount(w http.ResponseWriter, r *http.Request) (string, *Ac
// holder cares about. For a non-ops account every refusal below reveals
// only the caller's own account's state, so the 409 is not the cross-tenant
// oracle the parent spec's §3 guards against.
+//
+// The optional "place" field (2026-08-06 ruling: `carlos apps create -place
+// fleet/<f>`) is validated with the EXACT SAME two checks
+// handleAPIPlace applies to its target — grammar (registry.FleetPlacement)
+// and that the fleet belongs to the CALLING account — before claimApp is
+// ever called, and with the same refusal: an unknown-or-foreign fleet is
+// the byte-identical 404 apiFleet gives everywhere else, never a 400 that
+// would leak "that fleet exists, just not for you". Creating with an
+// invalid place must not be a weaker gate than `carlos apps place` run
+// straight after create — this is what keeps the two equal.
func (s *Server) handleAPICreateApp(w http.ResponseWriter, r *http.Request) {
addr, acct, ok := s.apiAccount(w, r)
if !ok {
@@ -160,13 +171,41 @@ func (s *Server) handleAPICreateApp(w http.ResponseWriter, r *http.Request) {
return
}
var req struct {
- Name string `json:"name"`
+ Name string `json:"name"`
+ Place string `json:"place"`
}
if !readJSON(w, r, &req) {
return
}
name := normalizeAppName(req.Name)
- switch s.claimApp(r.Context(), acct, name, addr) {
+ place := strings.TrimSpace(req.Place)
+ if place != "" {
+ fleetName, validShape := registry.FleetPlacement(place)
+ if !validShape {
+ jsonError(w, http.StatusBadRequest, `invalid place — want "" or "fleet/<name>"`)
+ return
+ }
+ reg, err := openRegistry(s.cfg.Registry)
+ if err != nil {
+ log.Printf("console: create app %s: open registry: %v", acct.Sqid, err)
+ jsonError(w, http.StatusInternalServerError, "could not reach the fleet registry")
+ return
+ }
+ f, err := reg.GetFleet(fleetName)
+ if err != nil {
+ log.Printf("console: create app %s: get fleet %s: %v", acct.Sqid, fleetName, err)
+ jsonError(w, http.StatusInternalServerError, "could not check the fleet")
+ return
+ }
+ if f == nil || f.Account != acct.Sqid {
+ // Same byte-identical 404 apiFleet and handleAPIPlace's target
+ // check both give an unknown-or-foreign fleet — see this
+ // handler's doc comment.
+ http.NotFound(w, r)
+ return
+ }
+ }
+ switch s.claimApp(r.Context(), acct, name, addr, place) {
case claimInvalid:
jsonError(w, http.StatusBadRequest,
"invalid app name — lowercase letters, digits, and dashes, starting with a letter")
diff --git a/internal/console/apicli_test.go b/internal/console/apicli_test.go
index 6efab0315..8564fee0b 100644
--- a/internal/console/apicli_test.go
+++ b/internal/console/apicli_test.go
@@ -2013,6 +2013,88 @@ func TestAPICreateAppHappy(t *testing.T) {
}
}
+// TestAPICreateAppWithPlace: `carlos apps create -place fleet/<f>` (the
+// 2026-08-06 ruling) stores the placement on the app record at create time
+// — the store-of-record appPlacement now consults FIRST, which is what
+// makes this work for an app with zero routes (see
+// TestAPIInstanceCreateInheritsRouteLessPlacedApp, apiinstances_test.go).
+func TestAPICreateAppWithPlace(t *testing.T) {
+ fx := newConsoleFixture(t)
+ token := cliToken(t, fx, "paul@keymail.dev", "code-create-place")
+
+ if status, body := postJSON(t, fx.ts.URL+"/api/cli/fleets/bab/new",
+ map[string]string{"name": "pi", "bucket": "pi-fleet-data"}, token); status != http.StatusOK {
+ t.Fatalf("create fleet = %d %v, want 200", status, body)
+ }
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new",
+ map[string]string{"name": "titogo", "place": "fleet/pi"}, token)
+ if status != http.StatusOK {
+ t.Fatalf("create with place = %d %v, want 200", status, out)
+ }
+ p, err := fx.st.AppPlacement(OpsAccountID, "titogo")
+ if err != nil || p != "fleet/pi" {
+ t.Fatalf("AppPlacement after create-with-place = %q %v, want fleet/pi", p, err)
+ }
+
+ // Clearing side: a create with NO place at all still leaves the app
+ // unplaced, exactly as before this field existed.
+ status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "unplaced"}, token)
+ if status != http.StatusOK {
+ t.Fatalf("plain create = %d %v, want 200", status, out)
+ }
+ if p, err := fx.st.AppPlacement(OpsAccountID, "unplaced"); err != nil || p != "" {
+ t.Fatalf("AppPlacement of a plain create = %q %v, want \"\"", p, err)
+ }
+}
+
+// TestAPICreateAppWithInvalidPlaceShape400: a place value that doesn't parse
+// as "fleet/<name>" is a plain 400 — the same grammar check `carlos apps
+// place` applies to its target — and, critically, the app is NOT created:
+// create-with-place must not be weaker than place, and it must not be
+// stronger in the other direction either (a bad place silently ignored,
+// leaving an app the caller believes is placed).
+func TestAPICreateAppWithInvalidPlaceShape400(t *testing.T) {
+ fx := newConsoleFixture(t)
+ token := cliToken(t, fx, "paul@keymail.dev", "code-create-place-badshape")
+
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new",
+ map[string]string{"name": "titogo", "place": "eu"}, token)
+ if status != http.StatusBadRequest {
+ t.Fatalf("create with a bare region code place = %d %v, want 400", status, out)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, "titogo"); err != nil || has {
+ t.Fatalf("HasAppIn after a refused place = %v %v, want false — nothing should have been created", has, err)
+ }
+}
+
+// TestAPICreateAppWithForeignPlace404: a place value naming a fleet owned by
+// a DIFFERENT account is the same byte-identical 404 apiFleet and
+// handleAPIPlace's own target check give everywhere else — and, again, the
+// app is not created. Creating with a foreign place must refuse exactly as
+// hard as `carlos apps place` onto the same foreign fleet does.
+func TestAPICreateAppWithForeignPlace404(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ token := cliToken(t, fx, "paul@keymail.dev", "code-create-place-foreign")
+
+ if err := fx.st.CreateAccount(42, "cdef", "widgets", "bob@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ bobToken := cliToken(t, fx, "bob@keymail.dev", "code-create-place-foreign-bob")
+ if status, body := postJSON(t, fx.ts.URL+"/api/cli/fleets/cdef/new",
+ map[string]string{"name": "acme", "bucket": "bob-data"}, bobToken); status != http.StatusOK {
+ t.Fatalf("bob's fleet create = %d %v, want 200", status, body)
+ }
+
+ want := canonicalNotFound(t)
+ status, h, out := rawReq(t, http.MethodPost, fx.ts.URL+"/api/cli/apps/bab/new", token,
+ `{"name":"titogo","place":"fleet/acme"}`)
+ assertNotFound(t, "create with a foreign-owned place target", want, status, h, out)
+ if has, err := fx.st.HasAppIn(OpsAccountID, "titogo"); err != nil || has {
+ t.Fatalf("HasAppIn after a refused foreign place = %v %v, want false", has, err)
+ }
+}
+
// TestAPICreateAppRefusalMatrix: the doors that must NOT open, and the shapes
// of their refusals. The one that matters most is byte-identical 404s — an
// account that doesn't exist and an account the token's holder doesn't belong
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go
index 222b73073..b32ea1090 100644
--- a/internal/console/apiinstances.go
+++ b/internal/console/apiinstances.go
@@ -254,7 +254,7 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request)
jsonError(w, http.StatusBadGateway, "could not check this app's placement")
return
}
- placed, err := appPlacement(reg, acct.Sqid, app)
+ placed, err := s.appPlacement(reg, acct, app)
if err != nil {
log.Printf("console: api instances create %s/%s: check placement: %v", acct.Sqid, app, err)
jsonError(w, http.StatusBadGateway, "could not check this app's placement")
diff --git a/internal/console/apiinstances_test.go b/internal/console/apiinstances_test.go
index 49dfe5963..c1f4dc1c2 100644
--- a/internal/console/apiinstances_test.go
+++ b/internal/console/apiinstances_test.go
@@ -414,6 +414,64 @@ func TestAPIInstanceCreateInheritsFleetPlacement(t *testing.T) {
}
}
+// TestAPIInstanceCreateInheritsRouteLessPlacedApp is the fix for the
+// limitation TestAPIInstanceCreateInheritsFleetPlacement's doc comment used
+// to describe: an app placed at CREATE TIME (`carlos apps create -place
+// fleet/<f>`, 2026-08-06 ruling) has ZERO routes at the instant of
+// placement — nothing for the old route-derived appPlacement to find — yet
+// its first instance must still inherit the target, because the store's
+// placement column (not routes) is now the source of truth. Unlike
+// TestAPIInstanceCreateInheritsFleetPlacement, no route is ever seeded here.
+func TestAPIInstanceCreateInheritsRouteLessPlacedApp(t *testing.T) {
+ fx := newConsoleFixture(t)
+ token := cliToken(t, fx, "paul@keymail.dev", "code-inst-routeless-placed")
+
+ if status, body := postJSON(t, fx.ts.URL+"/api/cli/fleets/"+fx.sqid+"/new",
+ map[string]string{"name": "pi", "bucket": "pi-fleet-data"}, token); status != http.StatusOK {
+ t.Fatalf("create fleet = %d %v, want 200", status, body)
+ }
+ // The app itself is created THROUGH the API, carrying place in the same
+ // call — the exact CLI surface this test exists for, and the only way an
+ // app can reach instance-create with a placement and genuinely zero
+ // routes (a pre-seeded fixture app would not prove the store path).
+ if status, body := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new",
+ map[string]string{"name": "titogo", "place": "fleet/pi"}, token); status != http.StatusOK {
+ t.Fatalf("create app with place = %d %v, want 200", status, body)
+ }
+ enableInstances(t, fx, "titogo", titoManifest()) // Regions: []string{"ie"}
+
+ // Sanity: genuinely zero routes for this app — the case route-derivation
+ // alone cannot answer.
+ reg, err := registry.Open(fx.regPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ routes, err := reg.List()
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, rt := range routes {
+ if rt.Account == fx.sqid && rt.App == "titogo" {
+ t.Fatalf("titogo already has a route (%s) — this test needs zero to prove the store path", rt.Host)
+ }
+ }
+
+ u := instancesURL(fx, fx.sqid, "titogo")
+ // "us" is outside titoManifest's ["ie"] allow-list — accepted (and
+ // overridden) here only because the store's placement inherits.
+ status, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"acme.go.tito.io","region":"us"}`)
+ if status != http.StatusCreated {
+ t.Fatalf("create on a route-less placed app = %d %s, want 201", status, out)
+ }
+ var rec provision.Record
+ if err := json.Unmarshal([]byte(out), &rec); err != nil {
+ t.Fatalf("create body %s: %v", out, err)
+ }
+ if rec.Region != "fleet/pi" {
+ t.Fatalf("record Region = %q, want the inherited fleet/pi (body region ignored)", rec.Region)
+ }
+}
+
// TestAPIInstanceCreateUnplacedHonorsBodyRegion is the regression guard for
// the inheritance check above: without a placement, the existing contract
// is unchanged — the body's region is validated against the manifest's
diff --git a/internal/console/fleets.go b/internal/console/fleets.go
index 63780423e..65c90593e 100644
--- a/internal/console/fleets.go
+++ b/internal/console/fleets.go
@@ -380,13 +380,21 @@ func (s *Server) handleAPIFleetDetachBox(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, map[string]any{})
}
-// appPlacement reports the fleet placement target already stamped on an
-// app's routes ("fleet/<name>"), or "" for no placement. handleAPIPlace
-// stamps every one of an app's routes with the SAME target in one call, so
-// by construction they are uniform once placed; the first fleet-shaped
-// Region found among the app's routes is authoritative and this does not
-// (and need not) reconcile a route set that predates that invariant — no
-// such state is reachable through this API.
+// appPlacement reports an app's fleet placement target ("fleet/<name>"), or
+// "" for none.
+//
+// 2026-08-06 ruling: the STORE's placement column (Store.AppPlacement) is
+// consulted FIRST — the fact `carlos apps place` and `carlos apps create
+// -place` both write (handleAPIPlace, handleAPICreateApp) — because it is
+// the only source that exists the instant an app is placed with zero
+// routes yet, which a route-derived reading structurally cannot answer (see
+// the KNOWN LIMITATION this comment used to carry, below). Only when the
+// store has never recorded a placement for this app (an app placed by an
+// OLDER build, before this column existed, still carries its placement
+// only in its routes) does this fall back to the original route-derived
+// reading: handleAPIPlace stamps every one of an app's routes with the SAME
+// target in one call, so by construction they are uniform once placed, and
+// the first fleet-shaped Region found among them is authoritative.
//
// The one caller outside this file is handleAPIInstanceCreate
// (apiinstances.go): a fleet-placed app's NEW instances must inherit its
@@ -395,26 +403,22 @@ func (s *Server) handleAPIFleetDetachBox(w http.ResponseWriter, r *http.Request)
// happens to send — see handleAPIPlace's doc comment for why that
// inheritance has to be a separate mechanism from the route/record stamping
// below, not the same one.
-//
-// KNOWN LIMITATION: an app placed before it has EVER had a route (never
-// `carlos add`-ed, never had an instance provisioned and reconciled onto a
-// box) has nothing here to derive from — handleAPIPlace's own route walk is
-// a no-op when the app owns no routes yet, so this returns "" and that
-// app's very first instance would NOT inherit the placement, even though
-// `carlos apps place` was just told it succeeded. Reachable in particular
-// for `carlos apps create -place fleet/<name>` (spec's CLI surface, a later
-// task): the app has zero routes at the instant of placement by
-// construction. Not fixed here — doing so needs a placement fact that
-// outlives having no routes at all (an app-level record, not a
-// derived-from-routes one), which is a bigger change than this task's
-// scope; flagged for whichever task wires up `-place`.
-func appPlacement(reg *registry.Registry, acctSqid, app string) (string, error) {
+func (s *Server) appPlacement(reg *registry.Registry, acct *Account, app string) (string, error) {
+ stored, err := s.store.AppPlacement(acct.ID, app)
+ if err != nil {
+ return "", err
+ }
+ if stored != "" {
+ return stored, nil
+ }
+ // Fallback: route-derived placement, for an app placed before the store
+ // column existed (see the doc comment above).
routes, err := reg.List()
if err != nil {
return "", err
}
for _, rt := range routes {
- if rt.Account != acctSqid || rt.App != app {
+ if rt.Account != acct.Sqid || rt.App != app {
continue
}
if _, ok := registry.FleetPlacement(rt.Region); ok {
@@ -517,6 +521,19 @@ func (s *Server) handleAPIPlace(w http.ResponseWriter, r *http.Request) {
}
}
+ // The store-of-record write (2026-08-06 ruling): appPlacement consults
+ // THIS before it ever walks a route. It has to land before routes/records
+ // are stamped below, not after — the same "never observe an app in a
+ // getting-placed state that some OTHER path already trusts" reasoning
+ // the ORDER MATTERS paragraph below applies to routes-before-records, one
+ // step earlier: a concurrent instance-create must never inherit a target
+ // this request has not actually committed to.
+ if err := s.store.SetAppPlacement(acct.ID, app, target); err != nil {
+ log.Printf("console: place %s/%s: set placement: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusInternalServerError, "could not record placement — try again")
+ return
+ }
+
routes, err := reg.List()
if err != nil {
log.Printf("console: place %s/%s: list routes: %v", acct.Sqid, app, err)
diff --git a/internal/console/fleets_test.go b/internal/console/fleets_test.go
index 8dd9bead6..969a34090 100644
--- a/internal/console/fleets_test.go
+++ b/internal/console/fleets_test.go
@@ -280,6 +280,40 @@ func placeURL(fx *consoleFixture, app string) string {
return fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/" + app + "/place"
}
+// TestAppPlacementFallsBackToRouteWhenStoreEmpty: an app placed by an OLDER
+// build — before the store's placement column existed (2026-08-06 ruling) —
+// has its placement fact only in its routes, stamped directly rather than
+// through handleAPIPlace (which now also writes the store). appPlacement
+// must still find it: Store.AppPlacement answers "" for this app, so the
+// derivation falls back to walking routes, exactly as it always did before
+// the store became the primary source.
+func TestAppPlacementFallsBackToRouteWhenStoreEmpty(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ reg := openTestReg(t, fx)
+ if err := reg.Upsert(registry.Route{
+ Host: "acme.go.tito.io", Kind: "instance", App: "titogo", Account: fx.sqid, Region: "fleet/pi",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ // Sanity: the store genuinely has nothing recorded for this app.
+ if p, err := fx.st.AppPlacement(OpsAccountID, "titogo"); err != nil || p != "" {
+ t.Fatalf("store placement before appPlacement runs = %q %v, want \"\" (unset)", p, err)
+ }
+
+ acct := &Account{ID: OpsAccountID, Sqid: fx.sqid}
+ got, err := fx.srv.appPlacement(reg, acct, "titogo")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != "fleet/pi" {
+ t.Fatalf("appPlacement = %q, want the route-derived fleet/pi", got)
+ }
+}
+
// TestPlaceStampsRoutesAndInstances is the tripwire test the brief calls out
// by name: placing an app stamps BOTH its routes' Region (walked from the
// registry, SetPlacement per host) AND its already-provisioned instance
diff --git a/internal/console/server.go b/internal/console/server.go
index e3859e57c..70a60f5b9 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -1346,7 +1346,22 @@ func normalizeAppName(name string) string {
// apps/<name>/. This is the ONLY place the claim checks live: both doors
// call it, so they cannot drift apart. Authentication, rate limits, the
// audit row and the response are the callers' business.
-func (s *Server) claimApp(ctx context.Context, acct *Account, name, addr string) claimOutcome {
+//
+// placement is an ALREADY-VALIDATED "" or "fleet/<name>" string — the
+// caller (handleAPICreateApp) checks grammar and fleet ownership itself,
+// the same inline checks handleAPIPlace's target uses, BEFORE calling this,
+// so an invalid or foreign placement never reaches here at all and never
+// costs a claim attempt. handleNewApp (the browser door) has no placement
+// concept and always passes "". On a successful claim the placement is
+// stamped as a SEPARATE store write, after CreateAppIn — not threaded into
+// that INSERT — because CreateAppIn has many other, unrelated callers; a
+// failure of this second write is logged and does not undo the claim (the
+// same best-effort shape ensureAccountManifest below already uses): an app
+// that exists but isn't placed is recoverable with `carlos apps place`,
+// while failing the whole request after the row is already committed would
+// leave the caller unable to tell whether retrying `apps create` means
+// "taken" or "try again".
+func (s *Server) claimApp(ctx context.Context, acct *Account, name, addr, placement string) claimOutcome {
if !release.ValidApp(name) {
return claimInvalid
}
@@ -1400,6 +1415,14 @@ func (s *Server) claimApp(ctx context.Context, acct *Account, name, addr string)
if !created { // lost a claim race after the checks
return claimTaken
}
+ if placement != "" {
+ if err := s.store.SetAppPlacement(acct.ID, name, placement); err != nil {
+ // Best-effort, like ensureAccountManifest below: the claim itself
+ // already succeeded and must not be undone by this — see this
+ // function's doc comment for why.
+ log.Printf("console: claim %s/%s: set placement %s: %v", acct.Sqid, name, placement, err)
+ }
+ }
// The app→account linkage needs no manifest of its own any more: it IS the
// bucket key path (apps/<sqid>/<app>/…), written by the first ship. The
// account's own manifest is what the agent materializes, and minting
@@ -1428,7 +1451,7 @@ func (s *Server) handleNewApp(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, home, http.StatusSeeOther)
}
name := normalizeAppName(r.FormValue("name"))
- switch s.claimApp(r.Context(), acct, name, addr) {
+ switch s.claimApp(r.Context(), acct, name, addr, "") {
case claimInvalid:
fail("Invalid app name — lowercase letters, digits, and dashes, starting with a letter.")
case claimTaken:
diff --git a/internal/console/store.go b/internal/console/store.go
index e6d0fc23a..61470bd36 100644
--- a/internal/console/store.go
+++ b/internal/console/store.go
@@ -14,6 +14,7 @@ import (
"database/sql"
"encoding/base64"
"encoding/hex"
+ "errors"
"fmt"
"os"
"path/filepath"
@@ -206,6 +207,16 @@ func (s *Store) migrate() error {
// deleted account — see MaterializeAccounts.
`ALTER TABLE apps ADD COLUMN deleted_at INTEGER NOT NULL DEFAULT 0;`,
`ALTER TABLE accounts ADD COLUMN deleted_at INTEGER NOT NULL DEFAULT 0;`,
+ // Placement (customer-fleets, 2026-08-06 ruling): the STORE OF RECORD
+ // for which fleet (if any) an app's routes/instances should run on —
+ // "" = unplaced. Route-derivation (fleets.go's appPlacement) used to
+ // be the only source, which left a freshly-created app with zero
+ // routes unable to carry a placement at all (`carlos apps create
+ // -place` would be a silent no-op). This column is what `carlos apps
+ // place` and apps-create's optional place field both write; every
+ // existing row defaults to "", identical to "never placed" under the
+ // old route-only reading.
+ `ALTER TABLE apps ADD COLUMN placement TEXT NOT NULL DEFAULT '';`,
} {
if _, err := s.db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err
@@ -257,14 +268,16 @@ func (s *Store) migrateAppsKey() error {
created_by TEXT NOT NULL,
created_at INTEGER NOT NULL,
deleted_at INTEGER NOT NULL DEFAULT 0,
+ placement TEXT NOT NULL DEFAULT '',
PRIMARY KEY (account_id, app)
);`,
// The ALTERs above run before migrateAppsKey in migrate(), so
- // deleted_at already exists on the old table by the time this rebuild
- // copies it — a row already trashed when the rebuild runs must not
- // have its trash state silently dropped.
- `INSERT INTO apps_new (app, account_id, created_by, created_at, deleted_at)
- SELECT app, account_id, created_by, created_at, deleted_at FROM apps;`,
+ // deleted_at and placement already exist on the old table by the time
+ // this rebuild copies them — a row already trashed, or already
+ // placed, when the rebuild runs must not have that state silently
+ // dropped.
+ `INSERT INTO apps_new (app, account_id, created_by, created_at, deleted_at, placement)
+ SELECT app, account_id, created_by, created_at, deleted_at, placement FROM apps;`,
`DROP TABLE apps;`,
`ALTER TABLE apps_new RENAME TO apps;`,
} {
@@ -1068,6 +1081,36 @@ func (s *Store) HasAppIn(accountID int64, app string) (bool, error) {
return n > 0, err
}
+// SetAppPlacement stamps an app's placement fact — the store-of-record
+// fleets.go's appPlacement consults FIRST, ahead of the older route-derived
+// fallback (2026-08-06 ruling). target "" clears it back to unplaced. The
+// row is guaranteed to exist here: every caller reaches this only after
+// apiAccountApp's HasAppIn gate (place) or a just-succeeded CreateAppIn
+// (create-with-place), so this is a plain UPDATE, never an upsert.
+func (s *Store) SetAppPlacement(accountID int64, app, target string) error {
+ _, err := s.db.Exec(`UPDATE apps SET placement = ? WHERE account_id = ? AND app = ?`,
+ target, accountID, app)
+ return err
+}
+
+// AppPlacement reads an app's stored placement fact: "" for unplaced (or a
+// row this column predates — the ALTER above defaults every existing row to
+// "", indistinguishable from "never placed", which is exactly right: the
+// caller's route-derived fallback is what recovers a real historical
+// placement for those rows). A missing row (an app appPlacement is asked
+// about that this store has no claim for at all) also reads as "", not an
+// error — the same "absence is un-placed" answer, for a caller that has
+// already proven the row exists as much as one that hasn't.
+func (s *Store) AppPlacement(accountID int64, app string) (string, error) {
+ var placement string
+ err := s.db.QueryRow(`SELECT placement FROM apps WHERE account_id = ? AND app = ?`, accountID, app).
+ Scan(&placement)
+ if errors.Is(err, sql.ErrNoRows) {
+ return "", nil
+ }
+ return placement, err
+}
+
// TrashAppRow marks an app trashed. Trashing twice keeps the FIRST timestamp
// (guarded by `deleted_at = 0`) — the cooloff clock starts once, and a second
// trash call (racing UI, a retried request) must not push the purge deadline
diff --git a/internal/console/store_test.go b/internal/console/store_test.go
index 594b4ce6f..c78ba5c52 100644
--- a/internal/console/store_test.go
+++ b/internal/console/store_test.go
@@ -476,6 +476,40 @@ func TestTrashAppRowRoundTrip(t *testing.T) {
}
}
+// TestAppPlacementRoundTrip: a fresh app reads unplaced, SetAppPlacement
+// stamps a target AppPlacement reads back, and clearing (target "") returns
+// it to unplaced — the store-of-record fleets.go's appPlacement now
+// consults first (2026-08-06 ruling), ahead of the older route-derived
+// fallback.
+func TestAppPlacementRoundTrip(t *testing.T) {
+ s := openTestStore(t)
+ now := time.Now()
+ acct := testAccountID()
+ if _, err := s.CreateAppIn(acct, "titogo", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if p, err := s.AppPlacement(acct, "titogo"); err != nil || p != "" {
+ t.Fatalf("AppPlacement of a fresh app = %q %v, want \"\" nil", p, err)
+ }
+ // An app this store has no row for at all also reads as unplaced, not an
+ // error.
+ if p, err := s.AppPlacement(acct, "no-such-app"); err != nil || p != "" {
+ t.Fatalf("AppPlacement of an unknown app = %q %v, want \"\" nil", p, err)
+ }
+ if err := s.SetAppPlacement(acct, "titogo", "fleet/pi"); err != nil {
+ t.Fatal(err)
+ }
+ if p, err := s.AppPlacement(acct, "titogo"); err != nil || p != "fleet/pi" {
+ t.Fatalf("AppPlacement after set = %q %v, want fleet/pi", p, err)
+ }
+ if err := s.SetAppPlacement(acct, "titogo", ""); err != nil {
+ t.Fatal(err)
+ }
+ if p, err := s.AppPlacement(acct, "titogo"); err != nil || p != "" {
+ t.Fatalf("AppPlacement after clear = %q %v, want \"\"", p, err)
+ }
+}
+
// TestAppCountCountsTrashedRows: account deletion's pre-flight must keep
// refusing while a trashed app's payload still exists, so the count is
// deliberately unfiltered by deleted_at.