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.
9 files changed,
+362
−32
internal/console/apicli.go+41 −2internal/console/apicli_test.go+82 −0internal/console/apiinstances.go+1 −1internal/console/apiinstances_test.go+58 −0internal/console/fleets.go+39 −22internal/console/fleets_test.go+34 −0internal/console/server.go+25 −2internal/console/store.go+48 −5internal/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. |