console: always-on is allowed on the account's own boxes
Under a plan, daemon releases, hibernate:false sidecars and schedules below the plan minimum are refused only on shared boxes; an app placed on one of the account's fleets may run all three (Paul, 2026-10-24).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+81
−6
docs/superpowers/plans/2026-10-23-plans-3b-never-sleep.md+4 −0internal/console/apicli.go+1 −1internal/console/apischedule.go+1 −1internal/console/apisidecars.go+1 −1internal/console/plancaps.go+35 −3internal/console/plancaps_doors_test.go+39 −0
CI cancelled — superseded by a newer push — run details
CI log
=== gate === .amadan/test-gate.sh amadan: job cancelled — a newer push superseded it
diff --git a/docs/superpowers/plans/2026-10-23-plans-3b-never-sleep.md b/docs/superpowers/plans/2026-10-23-plans-3b-never-sleep.md| index 2ddac2337..082341fe7 100644 |
| --- a/docs/superpowers/plans/2026-10-23-plans-3b-never-sleep.md |
| +++ b/docs/superpowers/plans/2026-10-23-plans-3b-never-sleep.md |
| @@ -10,6 +10,10 @@ |
| **Spec:** `docs/superpowers/specs/2026-10-20-plans-caps-packs-design.md` §5.2 (never sleeping is refused for plan-governed accounts; schedules: free minimum 15 minutes, paid 1 minute, validated where `ValidEvery` runs). Paul (2026-10-20): all apps hibernate; no plan offers always-on. Builds on Plans 1–3a. |
| +## Own boxes (Paul, 2026-10-24) |
| + |
| +Asked "Should we not just have plans that include daemons and sidecars? Or do we limit those to folks bringing their own boxes?", the answer taken (author's recommendation, matching the rootfs-apps ruling "own boxes only, always-on by default"): always-on is allowed when the app is placed on the account's own fleet (paid plans include fleets), and refused only on shared boxes. All three checks therefore pass when the app's placement is one of the account's fleets (`registry.FleetPlacement` of `appPlacement`); sidecars of a placed app inherit its fleet region. An always-on pack for shared boxes is a later pack (Plan 6), when someone asks. |
| + |
| ## Global Constraints |
| - No catalog, no change: every check returns nil when `EffectiveLimits` is nil. |
diff --git a/internal/console/apicli.go b/internal/console/apicli.go| index 95c30cd1f..f0f347547 100644 |
| --- a/internal/console/apicli.go |
| +++ b/internal/console/apicli.go |
| @@ -2011,7 +2011,7 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) { |
| // Plans 3b: a daemon never sleeps, and every plan's apps do. Before |
| // the identical-reship short-circuit, so a re-ship cannot slip past. |
| if declared.Daemon { |
| - if err := s.checkDaemon(acct); err != nil { |
| + if err := s.checkDaemon(acct, app); err != nil { |
| if isPlanCap(err) { |
| writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"}) |
| return |
diff --git a/internal/console/apischedule.go b/internal/console/apischedule.go| index c5fb19618..ff9dec4c8 100644 |
| --- a/internal/console/apischedule.go |
| +++ b/internal/console/apischedule.go |
| @@ -220,7 +220,7 @@ func (s *Server) handleAPIScheduleSet(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| // Plans 3b: a schedule may not run more often than the plan allows. |
| - if err := s.checkSchedule(acct, sc); err != nil { |
| + if err := s.checkSchedule(acct, app, sc); err != nil { |
| if isPlanCap(err) { |
| writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"}) |
| return |
diff --git a/internal/console/apisidecars.go b/internal/console/apisidecars.go| index 480bffdf3..77fd5f30e 100644 |
| --- a/internal/console/apisidecars.go |
| +++ b/internal/console/apisidecars.go |
| @@ -597,7 +597,7 @@ func (s *Server) handleAPISidecarEnable(w http.ResponseWriter, r *http.Request) |
| // asks for hibernate:false is refused; one that omits the field leaves an |
| // existing manifest as it was. |
| if req.Hibernate != nil && !*req.Hibernate { |
| - if err := s.checkAlwaysOn(acct); err != nil { |
| + if err := s.checkAlwaysOn(acct, app); err != nil { |
| if isPlanCap(err) { |
| writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"}) |
| return |
diff --git a/internal/console/plancaps.go b/internal/console/plancaps.go| index d733a342f..1e12d290c 100644 |
| --- a/internal/console/plancaps.go |
| +++ b/internal/console/plancaps.go |
| @@ -9,6 +9,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/schedule" |
| ) |
| @@ -293,32 +294,41 @@ func (s *Server) planPersonLock(addr string) func() { |
| // checkDaemon refuses a release that declares the daemon capability, which |
| // keeps an app running all the time (plans 3b; spec §5.2). |
| -func (s *Server) checkDaemon(acct *Account) error { |
| +func (s *Server) checkDaemon(acct *Account, app string) error { |
| l, err := s.EffectiveLimits(acct) |
| if err != nil || l == nil { |
| return err |
| } |
| + if own, err := s.onOwnBoxes(acct, app); err != nil || own { |
| + return err |
| + } |
| return &planCapError{msg: capMessage("cli.cap.daemon")} |
| } |
| // checkAlwaysOn refuses settings that would keep a sidecar awake for good |
| // (plans 3b; spec §5.2: no plan offers always-on). |
| -func (s *Server) checkAlwaysOn(acct *Account) error { |
| +func (s *Server) checkAlwaysOn(acct *Account, app string) error { |
| l, err := s.EffectiveLimits(acct) |
| if err != nil || l == nil { |
| return err |
| } |
| + if own, err := s.onOwnBoxes(acct, app); err != nil || own { |
| + return err |
| + } |
| return &planCapError{msg: capMessage("cli.cap.always_on")} |
| } |
| // checkSchedule refuses a schedule that would run more often than the |
| // plan's minimum interval (plans 3b; spec §5.2). Cron lines are held to |
| // their shortest gap between firings. |
| -func (s *Server) checkSchedule(acct *Account, sc schedule.Schedule) error { |
| +func (s *Server) checkSchedule(acct *Account, app string, sc schedule.Schedule) error { |
| l, err := s.EffectiveLimits(acct) |
| if err != nil || l == nil || l.MinScheduleSeconds <= 0 { |
| return err |
| } |
| + if own, err := s.onOwnBoxes(acct, app); err != nil || own { |
| + return err |
| + } |
| shortest, err := sc.ShortestInterval() |
| if err != nil { |
| return err |
| @@ -328,3 +338,25 @@ func (s *Server) checkSchedule(acct *Account, sc schedule.Schedule) error { |
| } |
| return nil |
| } |
| + |
| +// onOwnBoxes reports whether app is placed on one of the account's own |
| +// fleets. Always-on work there costs the deployment nothing, so the |
| +// never-sleep and schedule checks pass for it (Paul, 2026-10-24: always-on |
| +// on your own boxes). Sidecars of a placed app inherit its fleet region. |
| +func (s *Server) onOwnBoxes(acct *Account, app string) (bool, error) { |
| + placement, err := s.store.AppPlacement(acct.ID, app) |
| + if err != nil { |
| + return false, err |
| + } |
| + if placement == "" && s.cfg.Registry != "" { |
| + reg, err := openRegistry(s.cfg.Registry) |
| + if err != nil { |
| + return false, err |
| + } |
| + if placement, err = s.appPlacement(reg, acct, app); err != nil { |
| + return false, err |
| + } |
| + } |
| + _, ok := registry.FleetPlacement(placement) |
| + return ok, nil |
| +} |
diff --git a/internal/console/plancaps_doors_test.go b/internal/console/plancaps_doors_test.go| index df1ad7531..be1a883f5 100644 |
| --- a/internal/console/plancaps_doors_test.go |
| +++ b/internal/console/plancaps_doors_test.go |
| @@ -11,6 +11,7 @@ import ( |
| "github.com/carlosframework/platform/internal/plans" |
| "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -527,3 +528,41 @@ func TestScheduleWithoutACatalogUnchanged(t *testing.T) { |
| t.Fatalf("every 1m with no catalog = %d, want 200", status) |
| } |
| } |
| + |
| +// ---- Plans 3b: own boxes (Paul, 2026-10-24) ---- |
| + |
| +func placeOnFleet(t *testing.T, fx *consoleFixture, app string) { |
| + t.Helper() |
| + ops, _ := fx.st.AccountBySqid(fx.sqid) |
| + if err := fx.st.SetAppPlacement(ops.ID, app, registry.PlacementTarget("ownbox")); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestDaemonAllowedOnOwnBoxes(t *testing.T) { |
| + fx, token := daemonFixture(t, "code-cap-daemon-fleet") |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + placeOnFleet(t, fx, "relayapp") |
| + if status, out := shipDaemon(t, fx, token, "dmn00010"); status != http.StatusCreated { |
| + t.Fatalf("daemon ship for an app on its own fleet = %d %v, want 201", status, out) |
| + } |
| +} |
| + |
| +func TestAlwaysOnSidecarsAllowedOnOwnBoxes(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-cap-alwayson-fleet") |
| + enableSidecars(t, fx, "titogo", titoSidecarManifest()) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + placeOnFleet(t, fx, "titogo") |
| + if status, _, out := rawReq(t, http.MethodPut, sidecarEnableURL(fx, "bab", "titogo"), token, `{"hibernate":false}`); status != http.StatusOK { |
| + t.Fatalf("hibernate:false for an app on its own fleet = %d %s, want 200", status, out) |
| + } |
| +} |
| + |
| +func TestFrequentSchedulesAllowedOnOwnBoxes(t *testing.T) { |
| + fx, token := scheduleFixture(t, "code-cap-schedule-fleet") |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + placeOnFleet(t, fx, "titogo") |
| + if status := setSchedule(t, fx, token, `{"name":"minutely","every":"1m","path":"/jobs/x"}`); status != http.StatusOK { |
| + t.Fatalf("every 1m for an app on its own fleet = %d, want 200", status) |
| + } |
| +} |