carlos / platform Public

console: schedules meet the plan's minimum interval

schedule.ShortestInterval gives a cron line's smallest gap between
firings over a 62-day window, so cron cannot slip under the minimum.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev fc58c7288437ce98004f49824d3496b53db91050 parent 192bed3
5 files changed, +157 −0
  • internal/console/apischedule.go +10 −0
  • internal/console/plancaps.go +20 −0
  • internal/console/plancaps_doors_test.go +50 −0
  • internal/schedule/mingap.go +49 −0
  • internal/schedule/mingap_test.go +28 −0
diff --git a/internal/console/apischedule.go b/internal/console/apischedule.go
index ae63a84f5..c5fb19618 100644
--- a/internal/console/apischedule.go
+++ b/internal/console/apischedule.go
@@ -219,6 +219,16 @@ func (s *Server) handleAPIScheduleSet(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusBadRequest, err.Error())
return
}
+ // Plans 3b: a schedule may not run more often than the plan allows.
+ if err := s.checkSchedule(acct, sc); err != nil {
+ if isPlanCap(err) {
+ writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"})
+ return
+ }
+ log.Printf("console: api schedule set %s/%s: plan: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, planCheckFailedCopy)
+ return
+ }
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
// ReadDeclaration already folds a genuine not-found into an empty,
diff --git a/internal/console/plancaps.go b/internal/console/plancaps.go
index 8a701751c..d733a342f 100644
--- a/internal/console/plancaps.go
+++ b/internal/console/plancaps.go
@@ -6,8 +6,10 @@ import (
"fmt"
"net/http"
"strings"
+ "time"
"github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/schedule"
)
// Creation caps (plans-caps-packs spec §5.1, §8.1, §8.4; plans 3a). Every
@@ -308,3 +310,21 @@ func (s *Server) checkAlwaysOn(acct *Account) error {
}
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 {
+ l, err := s.EffectiveLimits(acct)
+ if err != nil || l == nil || l.MinScheduleSeconds <= 0 {
+ return err
+ }
+ shortest, err := sc.ShortestInterval()
+ if err != nil {
+ return err
+ }
+ if shortest < time.Duration(l.MinScheduleSeconds)*time.Second {
+ return &planCapError{msg: capMessage("cli.cap.schedule", l.MinScheduleSeconds/60)}
+ }
+ return nil
+}
diff --git a/internal/console/plancaps_doors_test.go b/internal/console/plancaps_doors_test.go
index 314347077..df1ad7531 100644
--- a/internal/console/plancaps_doors_test.go
+++ b/internal/console/plancaps_doors_test.go
@@ -477,3 +477,53 @@ func TestSidecarEnableHibernateFalseWithoutACatalogUnchanged(t *testing.T) {
t.Fatalf("hibernate:false with no catalog = %d %s, want 200", status, out)
}
}
+
+func setSchedule(t *testing.T, fx *consoleFixture, token, body string) int {
+ t.Helper()
+ status, _ := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules", token, body)
+ return status
+}
+
+func TestScheduleRefusedBelowThePlanMinimum(t *testing.T) {
+ fx, token := scheduleFixture(t, "code-cap-schedule")
+ fx.srv.cfg.PlanCatalog = examplePlanCatalog(t)
+ for _, body := range []string{
+ `{"name":"often","every":"5m","path":"/jobs/x"}`,
+ `{"name":"often","cron":"*/1 * * * *","path":"/jobs/x"}`,
+ } {
+ status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules", token, body)
+ if status != http.StatusConflict || !strings.Contains(string(out), `"code":"cap"`) {
+ t.Errorf("%s on free = %d %s, want 409 cap", body, status, out)
+ }
+ }
+ if _, etag := declaredSchedules(t, fx, "titogo"); etag != "" {
+ t.Fatal("a refused schedule was written")
+ }
+ for _, body := range []string{
+ `{"name":"quarter","every":"15m","path":"/jobs/x"}`,
+ `{"name":"quartercron","cron":"*/15 * * * *","path":"/jobs/y"}`,
+ } {
+ if status := setSchedule(t, fx, token, body); status != http.StatusOK {
+ t.Errorf("%s on free = %d, want 200", body, status)
+ }
+ }
+}
+
+func TestScheduleMinimumOnAPaidPlan(t *testing.T) {
+ fx, token := scheduleFixture(t, "code-cap-schedule-paid")
+ fx.srv.cfg.PlanCatalog = examplePlanCatalog(t)
+ ops, _ := fx.st.AccountBySqid(fx.sqid)
+ if _, err := fx.st.SetAccountPlan(ops.ID, "personal", nil, PlanSourceOperator, nil, "r", "ops@example.com", planNow); err != nil {
+ t.Fatal(err)
+ }
+ if status := setSchedule(t, fx, token, `{"name":"minutely","every":"1m","path":"/jobs/x"}`); status != http.StatusOK {
+ t.Fatalf("every 1m on personal = %d, want 200", status)
+ }
+}
+
+func TestScheduleWithoutACatalogUnchanged(t *testing.T) {
+ fx, token := scheduleFixture(t, "code-cap-schedule-nocat")
+ if status := setSchedule(t, fx, token, `{"name":"minutely","every":"1m","path":"/jobs/x"}`); status != http.StatusOK {
+ t.Fatalf("every 1m with no catalog = %d, want 200", status)
+ }
+}
diff --git a/internal/schedule/mingap.go b/internal/schedule/mingap.go
new file mode 100644
index 000000000..38b1476e8
--- /dev/null
+++ b/internal/schedule/mingap.go
@@ -0,0 +1,49 @@
+package schedule
+
+import (
+ "errors"
+ "time"
+)
+
+// shortestWindow is how far ShortestInterval looks for a cron line's
+// firings: long enough to see two of a monthly line's.
+const shortestWindow = 62 * 24 * time.Hour
+
+// shortestFrom is a fixed starting instant (a Monday at midnight UTC), so
+// the answer never depends on when it is asked.
+var shortestFrom = time.Date(2026, 1, 5, 0, 0, 0, 0, time.UTC)
+
+// ShortestInterval is the least time between two runs of s: its every:
+// duration, or for a cron line the smallest gap between consecutive firings
+// across shortestWindow. A line that fires at most once in the window
+// answers the window. Plans 3b uses it to hold schedules to a plan's
+// minimum.
+func (s Schedule) ShortestInterval() (time.Duration, error) {
+ sp, err := s.Spec()
+ if err != nil {
+ return 0, err
+ }
+ switch {
+ case sp.Every > 0:
+ return sp.Every, nil
+ case sp.Cron != nil:
+ shortest := shortestWindow
+ end := shortestFrom.Add(shortestWindow)
+ prev := sp.Cron.Next(shortestFrom)
+ for !prev.IsZero() && prev.Before(end) {
+ next := sp.Cron.Next(prev)
+ if next.IsZero() || !next.Before(end) {
+ break
+ }
+ if gap := next.Sub(prev); gap < shortest {
+ shortest = gap
+ if shortest <= MinEvery {
+ break
+ }
+ }
+ prev = next
+ }
+ return shortest, nil
+ }
+ return 0, errors.New("schedule has no interval")
+}
diff --git a/internal/schedule/mingap_test.go b/internal/schedule/mingap_test.go
new file mode 100644
index 000000000..20fbf3b14
--- /dev/null
+++ b/internal/schedule/mingap_test.go
@@ -0,0 +1,28 @@
+package schedule
+
+import (
+ "testing"
+ "time"
+)
+
+func TestCronMinGap(t *testing.T) {
+ for _, c := range []struct {
+ sc Schedule
+ want time.Duration
+ }{
+ {Schedule{Name: "a", Cron: "*/1 * * * *", Path: "/x"}, time.Minute},
+ {Schedule{Name: "a", Cron: "*/15 * * * *", Path: "/x"}, 15 * time.Minute},
+ {Schedule{Name: "a", Cron: "0,5 * * * *", Path: "/x"}, 5 * time.Minute},
+ {Schedule{Name: "a", Cron: "0 3 * * *", Path: "/x"}, 24 * time.Hour},
+ {Schedule{Name: "a", Cron: "0 9 * * 1", Path: "/x"}, 7 * 24 * time.Hour},
+ {Schedule{Name: "a", Every: "10m", Path: "/x"}, 10 * time.Minute},
+ } {
+ got, err := c.sc.ShortestInterval()
+ if err != nil || got != c.want {
+ t.Errorf("%+v: ShortestInterval = %v, %v; want %v", c.sc, got, err, c.want)
+ }
+ }
+ if _, err := (Schedule{Name: "a", Cron: "nope", Path: "/x"}).ShortestInterval(); err == nil {
+ t.Error("an invalid cron line had a shortest interval")
+ }
+}