carlos / platform Public

schedule: specs, cron dialect, declarations and status objects

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 852fa708d21da4d420d68ed576eec7399ca8c416 parent f83fd7e
8 files changed, +925 −0
  • internal/schedule/cron.go +169 −0
  • internal/schedule/cron_test.go +63 −0
  • internal/schedule/declaration.go +266 −0
  • internal/schedule/declaration_test.go +114 −0
  • internal/schedule/spec.go +108 −0
  • internal/schedule/spec_test.go +60 −0
  • internal/schedule/status.go +108 −0
  • internal/schedule/status_test.go +37 −0
diff --git a/internal/schedule/cron.go b/internal/schedule/cron.go
new file mode 100644
index 000000000..90f821832
--- /dev/null
+++ b/internal/schedule/cron.go
@@ -0,0 +1,169 @@
+// Package schedule is the platform's answer to "run this later": the WHEN of
+// scheduled work, so an app that hibernates does not have to keep a process
+// alive to keep a timer. Spec: docs/superpowers/specs/2026-08-23-scheduled-work-design.md.
+//
+// This file is the cron dialect. Deliberately small (five fields, UTC, no
+// names, no @aliases — spec §4.1) and deliberately ours: the box binary is
+// built with GOPROXY=off, and a parser this size is cheaper to own than to
+// vendor.
+package schedule
+
+import (
+ "errors"
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// CronExpr is a parsed five-field expression. Each field is a bitset over
+// its legal range (minute 0-59, hour 0-23, day-of-month 1-31, month 1-12,
+// day-of-week 0-6 with 7 folded onto 0).
+type CronExpr struct {
+ min, hour, dom, month, dow uint64
+ raw string
+ domStar, dowStar bool // "*" in a day field — the classic OR-vs-AND rule below
+}
+
+func (c *CronExpr) String() string { return c.raw }
+
+// ParseCron accepts "min hour dom month dow" with *, n, a-b, */n, a-b/n and
+// comma lists. Anything else — names, six fields, @daily — is an error with
+// the field named, because the member typing it has no box to debug on.
+func ParseCron(expr string) (*CronExpr, error) {
+ fields := strings.Fields(expr)
+ if len(fields) != 5 {
+ return nil, fmt.Errorf("cron: want 5 fields (min hour day month weekday), got %d", len(fields))
+ }
+ c := &CronExpr{raw: strings.Join(fields, " ")}
+ var err error
+ if c.min, _, err = parseField(fields[0], 0, 59, "minute"); err != nil {
+ return nil, err
+ }
+ if c.hour, _, err = parseField(fields[1], 0, 23, "hour"); err != nil {
+ return nil, err
+ }
+ if c.dom, c.domStar, err = parseField(fields[2], 1, 31, "day"); err != nil {
+ return nil, err
+ }
+ if c.month, _, err = parseField(fields[3], 1, 12, "month"); err != nil {
+ return nil, err
+ }
+ if c.dow, c.dowStar, err = parseField(fields[4], 0, 7, "weekday"); err != nil {
+ return nil, err
+ }
+ if c.dow&(1<<7) != 0 { // 7 is Sunday too
+ c.dow = (c.dow &^ (1 << 7)) | 1
+ }
+ return c, nil
+}
+
+// parseField returns the bitset and whether the field was a bare "*".
+func parseField(f string, lo, hi int, name string) (uint64, bool, error) {
+ var bits uint64
+ star := f == "*"
+ for _, part := range strings.Split(f, ",") {
+ rangePart, stepPart, hasStep := strings.Cut(part, "/")
+ step := 1
+ if hasStep {
+ n, err := strconv.Atoi(stepPart)
+ if err != nil || n <= 0 {
+ return 0, false, fmt.Errorf("cron %s: bad step %q", name, part)
+ }
+ step = n
+ }
+ a, b := lo, hi
+ switch {
+ case rangePart == "*":
+ case strings.Contains(rangePart, "-"):
+ as, bs, _ := strings.Cut(rangePart, "-")
+ var err error
+ if a, err = atoiIn(as, lo, hi); err != nil {
+ return 0, false, fmt.Errorf("cron %s: %w", name, err)
+ }
+ if b, err = atoiIn(bs, lo, hi); err != nil {
+ return 0, false, fmt.Errorf("cron %s: %w", name, err)
+ }
+ if a > b {
+ return 0, false, fmt.Errorf("cron %s: range %q runs backwards", name, rangePart)
+ }
+ default:
+ n, err := atoiIn(rangePart, lo, hi)
+ if err != nil {
+ return 0, false, fmt.Errorf("cron %s: %w", name, err)
+ }
+ a, b = n, n
+ if hasStep {
+ b = hi // "5/10" means from 5 to the end, stepping
+ }
+ }
+ for v := a; v <= b; v += step {
+ bits |= 1 << uint(v)
+ }
+ }
+ if bits == 0 {
+ return 0, false, errors.New("cron " + name + ": empty")
+ }
+ return bits, star, nil
+}
+
+func atoiIn(s string, lo, hi int) (int, error) {
+ n, err := strconv.Atoi(s)
+ if err != nil {
+ return 0, fmt.Errorf("%q is not a number", s)
+ }
+ if n < lo || n > hi {
+ return 0, fmt.Errorf("%d is outside %d-%d", n, lo, hi)
+ }
+ return n, nil
+}
+
+// Next is the first matching minute strictly after `after`, in UTC, or the
+// zero time if none falls within the search window (a "0 0 30 2 *" never
+// matches, and terminates quickly since each mismatched day is a single
+// jump, not a minute-by-minute walk). The window is 5 years, not 366 days:
+// "0 0 29 2 *" (Feb 29) can be up to ~4 years out depending on where `after`
+// falls in the leap-year cycle, and a 366-day window would wrongly report
+// "never" for it.
+func (c *CronExpr) Next(after time.Time) time.Time {
+ t := after.UTC().Truncate(time.Minute).Add(time.Minute)
+ limit := t.AddDate(5, 0, 0)
+ for t.Before(limit) {
+ if c.month&(1<<uint(t.Month())) == 0 {
+ // Skip to the first of next month.
+ t = time.Date(t.Year(), t.Month()+1, 1, 0, 0, 0, 0, time.UTC)
+ continue
+ }
+ if !c.dayMatches(t) {
+ t = time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, time.UTC)
+ continue
+ }
+ if c.hour&(1<<uint(t.Hour())) == 0 {
+ t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour()+1, 0, 0, 0, time.UTC)
+ continue
+ }
+ if c.min&(1<<uint(t.Minute())) == 0 {
+ t = t.Add(time.Minute)
+ continue
+ }
+ return t
+ }
+ return time.Time{}
+}
+
+// dayMatches applies Vixie cron's rule: when BOTH day fields are restricted
+// the day matches if EITHER does; when one is "*" the other decides.
+func (c *CronExpr) dayMatches(t time.Time) bool {
+ dom := c.dom&(1<<uint(t.Day())) != 0
+ dow := c.dow&(1<<uint(t.Weekday())) != 0
+ switch {
+ case c.domStar && c.dowStar:
+ return true
+ case c.domStar:
+ return dow
+ case c.dowStar:
+ return dom
+ default:
+ return dom || dow
+ }
+}
diff --git a/internal/schedule/cron_test.go b/internal/schedule/cron_test.go
new file mode 100644
index 000000000..561ad87f4
--- /dev/null
+++ b/internal/schedule/cron_test.go
@@ -0,0 +1,63 @@
+package schedule
+
+import (
+ "testing"
+ "time"
+)
+
+func at(s string) time.Time {
+ t, err := time.Parse(time.RFC3339, s)
+ if err != nil {
+ panic(err)
+ }
+ return t.UTC()
+}
+
+func TestParseCronAcceptsTheGrammar(t *testing.T) {
+ for _, ok := range []string{"* * * * *", "0 8 * * *", "*/15 * * * *", "0 */6 * * *", "0 9 1-5 * *", "30 7 * * 1,3,5", "0 0 1 1 *", "0 0 * * 7", "0-59/20 * * * *"} {
+ if _, err := ParseCron(ok); err != nil {
+ t.Errorf("ParseCron(%q) = %v, want ok", ok, err)
+ }
+ }
+ for _, bad := range []string{"", "* * * *", "* * * * * *", "60 * * * *", "* 24 * * *", "* * 0 * *", "* * 32 * *", "* * * 13 *", "* * * * 8", "@daily", "*/0 * * * *", "5-3 * * * *", "a * * * *", "0 8 * * MON"} {
+ if _, err := ParseCron(bad); err == nil {
+ t.Errorf("ParseCron(%q) = ok, want error", bad)
+ }
+ }
+}
+
+func TestCronNextIsStrictlyAfterAndUTC(t *testing.T) {
+ cases := []struct{ expr, after, want string }{
+ {"0 8 * * *", "2026-08-23T07:59:00Z", "2026-08-23T08:00:00Z"},
+ {"0 8 * * *", "2026-08-23T08:00:00Z", "2026-08-24T08:00:00Z"}, // strictly after
+ {"0 8 * * *", "2026-08-23T08:00:30Z", "2026-08-24T08:00:00Z"}, // seconds truncated: 08:00 has passed
+ {"*/15 * * * *", "2026-08-23T08:07:00Z", "2026-08-23T08:15:00Z"},
+ {"0 */6 * * *", "2026-08-23T13:00:00Z", "2026-08-23T18:00:00Z"},
+ {"0 9 * * 1", "2026-08-23T09:00:00Z", "2026-08-24T09:00:00Z"}, // 2026-08-23 is a Sunday
+ {"0 0 31 * *", "2026-09-01T00:00:00Z", "2026-10-31T00:00:00Z"}, // September has no 31st
+ {"0 0 29 2 *", "2026-03-01T00:00:00Z", "2028-02-29T00:00:00Z"},
+ {"0 0 * * 7", "2026-08-23T00:00:00Z", "2026-08-30T00:00:00Z"}, // 7 = Sunday
+ }
+ for _, c := range cases {
+ expr, err := ParseCron(c.expr)
+ if err != nil {
+ t.Fatalf("%q: %v", c.expr, err)
+ }
+ if got := expr.Next(at(c.after)); !got.Equal(at(c.want)) {
+ t.Errorf("%q after %s = %s, want %s", c.expr, c.after, got.Format(time.RFC3339), c.want)
+ }
+ if got := expr.Next(at(c.after)); got.Location() != time.UTC {
+ t.Errorf("%q: Next not in UTC", c.expr)
+ }
+ }
+}
+
+func TestCronNextHonoursNonUTCInput(t *testing.T) {
+ expr, _ := ParseCron("0 8 * * *")
+ loc := time.FixedZone("plus2", 2*3600)
+ // 09:30 +02:00 is 07:30Z — the next 08:00Z is the same day.
+ got := expr.Next(time.Date(2026, 8, 23, 9, 30, 0, 0, loc))
+ if !got.Equal(at("2026-08-23T08:00:00Z")) {
+ t.Fatalf("got %s", got.Format(time.RFC3339))
+ }
+}
diff --git a/internal/schedule/declaration.go b/internal/schedule/declaration.go
new file mode 100644
index 000000000..4477a6af3
--- /dev/null
+++ b/internal/schedule/declaration.go
@@ -0,0 +1,266 @@
+package schedule
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "regexp"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+const MaxSchedulesPerApp = 20
+
+var nameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`)
+
+func ValidName(name string) bool { return nameRE.MatchString(name) }
+
+// ValidPath: absolute, no scheme/host, no "..", short. A path is what the
+// tick POSTs to on the instance's own socket — nothing here can reach
+// another host, but a bad one should fail at the terminal, not on the box.
+func ValidPath(p string) error {
+ switch {
+ case p == "" || p[0] != '/':
+ return errors.New("path must start with /")
+ case strings.HasPrefix(p, "//"):
+ return errors.New("path must not start with //")
+ case len(p) > 256:
+ return errors.New("path is longer than 256 bytes")
+ case strings.ContainsAny(p, " \t\r\n"):
+ return errors.New("path must not contain whitespace")
+ }
+ for _, seg := range strings.Split(strings.SplitN(p, "?", 2)[0], "/") {
+ if seg == ".." {
+ return errors.New("path must not contain ..")
+ }
+ }
+ return nil
+}
+
+// Schedule is one declared recurring job, as the member typed it.
+type Schedule struct {
+ Name string `json:"name"`
+ Every string `json:"every,omitempty"`
+ Cron string `json:"cron,omitempty"`
+ Path string `json:"path"`
+}
+
+// Spec is the schedule as the box stores it. Exactly one of every/cron.
+func (s Schedule) Spec() (Spec, error) {
+ switch {
+ case s.Every != "" && s.Cron != "":
+ return Spec{}, errors.New("give -every or -cron, not both")
+ case s.Every != "":
+ d, err := time.ParseDuration(s.Every)
+ if err != nil {
+ return Spec{}, fmt.Errorf("every: %w", err)
+ }
+ if err := ValidEvery(d); err != nil {
+ return Spec{}, err
+ }
+ return Spec{Kind: KindSchedule, Every: d}, nil
+ case s.Cron != "":
+ c, err := ParseCron(s.Cron)
+ if err != nil {
+ return Spec{}, err
+ }
+ return Spec{Kind: KindSchedule, Cron: c}, nil
+ }
+ return Spec{}, errors.New("give -every <duration> or -cron \"<5 fields>\"")
+}
+
+// Validate checks every field: name, path shape, and exactly one of
+// every/cron parses. It does not mutate the receiver — normalising
+// every/cron to their canonical text (so what lands in the bucket is what
+// ParseSpec reads back) is UpsertSchedule's job, done on its own addressable
+// copy just before storing.
+func (s Schedule) Validate() error {
+ if !ValidName(s.Name) {
+ return fmt.Errorf("name %q: want [a-z0-9][a-z0-9-]{0,31}", s.Name)
+ }
+ if err := ValidPath(s.Path); err != nil {
+ return err
+ }
+ _, err := s.Spec()
+ return err
+}
+
+// Declaration is apps/<acct>/<app>/schedules.json (spec §4.1).
+type Declaration struct {
+ Schema int `json:"schema"`
+ UpdatedAt string `json:"updated_at"`
+ Schedules []Schedule `json:"schedules"`
+}
+
+func (d Declaration) Find(name string) (Schedule, bool) {
+ for _, s := range d.Schedules {
+ if s.Name == name {
+ return s, true
+ }
+ }
+ return Schedule{}, false
+}
+
+func DeclarationKey(acct, app string) string { return release.AppPrefix(acct, app) + "schedules.json" }
+func RunKey(acct, app string) string { return release.AppPrefix(acct, app) + "schedule-run.json" }
+func StatusPrefix(acct, app string) string { return release.AppPrefix(acct, app) + "schedule-status/" }
+func StatusKey(acct, app, host string) string { return StatusPrefix(acct, app) + host + ".json" }
+
+// ReadDeclaration: absent is the empty declaration with etag "" — legal
+// forever, like restart-gen.
+func ReadDeclaration(ctx context.Context, s release.Store, acct, app string) (Declaration, string, error) {
+ raw, etag, err := s.Get(ctx, DeclarationKey(acct, app))
+ if errors.Is(err, os.ErrNotExist) {
+ return Declaration{Schema: 1}, "", nil
+ }
+ if err != nil {
+ return Declaration{}, "", err
+ }
+ var d Declaration
+ if err := json.Unmarshal(raw, &d); err != nil {
+ return Declaration{}, "", fmt.Errorf("schedules.json: %w", err)
+ }
+ return d, etag, nil
+}
+
+func checkIDs(acct, app string) error {
+ if !release.ValidAccount(acct) {
+ return fmt.Errorf("schedule: invalid account %q", acct)
+ }
+ if !release.ValidApp(app) {
+ return fmt.Errorf("schedule: invalid app %q", app)
+ }
+ return nil
+}
+
+// modify applies fn under CAS with ONE retry on conflict — two members
+// editing the same app at once should both land, not one of them fail.
+func modify(ctx context.Context, s release.Store, acct, app string, now time.Time, fn func(*Declaration) error) (Declaration, error) {
+ if err := checkIDs(acct, app); err != nil {
+ return Declaration{}, err
+ }
+ var last error
+ for attempt := 0; attempt < 2; attempt++ {
+ d, etag, err := ReadDeclaration(ctx, s, acct, app)
+ if err != nil {
+ return Declaration{}, err
+ }
+ if err := fn(&d); err != nil {
+ return Declaration{}, err
+ }
+ sort.Slice(d.Schedules, func(i, j int) bool { return d.Schedules[i].Name < d.Schedules[j].Name })
+ d.Schema = 1
+ d.UpdatedAt = now.UTC().Format(time.RFC3339)
+ raw, err := json.Marshal(d)
+ if err != nil {
+ return Declaration{}, err
+ }
+ if _, err := s.Put(ctx, DeclarationKey(acct, app), raw, etag); err != nil {
+ if errors.Is(err, release.ErrCASConflict) {
+ last = err
+ continue
+ }
+ return Declaration{}, err
+ }
+ return d, nil
+ }
+ return Declaration{}, fmt.Errorf("schedules.json changed underneath us twice: %w", last)
+}
+
+// UpsertSchedule validates sc, normalises its every/cron text to what
+// ParseSpec reads back, then replaces-or-appends it by name.
+func UpsertSchedule(ctx context.Context, s release.Store, acct, app string, sc Schedule, now time.Time) (Declaration, error) {
+ if err := sc.Validate(); err != nil {
+ return Declaration{}, err
+ }
+ sp, err := sc.Spec() // Validate already confirmed this succeeds
+ if err != nil {
+ return Declaration{}, err
+ }
+ if sp.Every > 0 {
+ sc.Every = sp.Every.String()
+ } else {
+ sc.Cron = sp.Cron.String()
+ }
+ return modify(ctx, s, acct, app, now, func(d *Declaration) error {
+ for i := range d.Schedules {
+ if d.Schedules[i].Name == sc.Name {
+ d.Schedules[i] = sc
+ return nil
+ }
+ }
+ if len(d.Schedules) >= MaxSchedulesPerApp {
+ return fmt.Errorf("an app may declare at most %d schedules", MaxSchedulesPerApp)
+ }
+ d.Schedules = append(d.Schedules, sc)
+ return nil
+ })
+}
+
+// RemoveSchedule drops name; present reports whether it was there. Removing
+// an absent name still rewrites updated_at — harmless, and it keeps the
+// door's answer uniform.
+func RemoveSchedule(ctx context.Context, s release.Store, acct, app, name string, now time.Time) (Declaration, bool, error) {
+ present := false
+ d, err := modify(ctx, s, acct, app, now, func(d *Declaration) error {
+ kept := d.Schedules[:0]
+ for _, sc := range d.Schedules {
+ if sc.Name == name {
+ present = true
+ continue
+ }
+ kept = append(kept, sc)
+ }
+ d.Schedules = kept
+ return nil
+ })
+ return d, present, err
+}
+
+// RunSignal is schedule-run.json: a touch object whose etag movement is the
+// signal, exactly restart-gen's shape and rules (release/restartgen.go).
+type RunSignal struct {
+ Schema int `json:"schema"`
+ Name string `json:"name"`
+ TouchedAt string `json:"touched_at"`
+ Nonce string `json:"nonce"`
+}
+
+// TouchRun bumps the run signal for name. A CAS conflict is success: the
+// signal already fired.
+func TouchRun(ctx context.Context, s release.Store, acct, app, name string, now time.Time) error {
+ if err := checkIDs(acct, app); err != nil {
+ return err
+ }
+ if !ValidName(name) {
+ return fmt.Errorf("schedule: invalid name %q", name)
+ }
+ key := RunKey(acct, app)
+ _, etag, err := s.Get(ctx, key)
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ if errors.Is(err, os.ErrNotExist) {
+ etag = ""
+ }
+ nonce := make([]byte, 16)
+ if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
+ return err
+ }
+ raw, err := json.Marshal(RunSignal{Schema: 1, Name: name, TouchedAt: now.UTC().Format(time.RFC3339), Nonce: hex.EncodeToString(nonce)})
+ if err != nil {
+ return err
+ }
+ if _, err := s.Put(ctx, key, raw, etag); err != nil && !errors.Is(err, release.ErrCASConflict) {
+ return err
+ }
+ return nil
+}
diff --git a/internal/schedule/declaration_test.go b/internal/schedule/declaration_test.go
new file mode 100644
index 000000000..0bcee8212
--- /dev/null
+++ b/internal/schedule/declaration_test.go
@@ -0,0 +1,114 @@
+package schedule
+
+import (
+ "context"
+ "encoding/json"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestValidNameAndPath(t *testing.T) {
+ for _, ok := range []string{"a", "sync", "daily-digest", "x1", strings.Repeat("a", 32)} {
+ if !ValidName(ok) {
+ t.Errorf("ValidName(%q) = false", ok)
+ }
+ }
+ for _, bad := range []string{"", "-a", "A", "a_b", "a b", strings.Repeat("a", 33), "sync/"} {
+ if ValidName(bad) {
+ t.Errorf("ValidName(%q) = true", bad)
+ }
+ }
+ for _, ok := range []string{"/", "/jobs/sync", "/a/b-c_d.e?x=1"} {
+ if err := ValidPath(ok); err != nil {
+ t.Errorf("ValidPath(%q) = %v", ok, err)
+ }
+ }
+ for _, bad := range []string{"", "jobs/sync", "/jobs/../etc", "https://x/y", "//host/p", "/" + strings.Repeat("a", 256)} {
+ if err := ValidPath(bad); err == nil {
+ t.Errorf("ValidPath(%q) = ok", bad)
+ }
+ }
+}
+
+func TestScheduleValidateNormalisesEvery(t *testing.T) {
+ s := Schedule{Name: "sync", Every: "6h", Path: "/jobs/sync"}
+ if err := s.Validate(); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := (Schedule{Name: "x", Every: "6h", Cron: "* * * * *", Path: "/p"}).Spec(); err == nil {
+ t.Error("both every and cron accepted")
+ }
+ if err := (Schedule{Name: "x", Path: "/p"}).Validate(); err == nil {
+ t.Error("neither every nor cron accepted")
+ }
+ if err := (Schedule{Name: "x", Every: "30s", Path: "/p"}).Validate(); err == nil {
+ t.Error("30s accepted")
+ }
+}
+
+func TestUpsertRemoveAndCAS(t *testing.T) {
+ ctx := context.Background()
+ st := release.NewDirStore(t.TempDir())
+ now := at("2026-08-23T22:00:00Z")
+ d, err := UpsertSchedule(ctx, st, "bab", "app", Schedule{Name: "sync", Every: "6h", Path: "/jobs/sync"}, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(d.Schedules) != 1 || d.Schema != 1 || d.UpdatedAt != "2026-08-23T22:00:00Z" {
+ t.Fatalf("declaration = %+v", d)
+ }
+ // Upsert by name replaces; a second name sorts in.
+ d, _ = UpsertSchedule(ctx, st, "bab", "app", Schedule{Name: "sync", Every: "12h", Path: "/jobs/sync"}, now)
+ d, _ = UpsertSchedule(ctx, st, "bab", "app", Schedule{Name: "alpha", Cron: "0 8 * * *", Path: "/jobs/a"}, now)
+ if len(d.Schedules) != 2 || d.Schedules[0].Name != "alpha" || d.Schedules[1].Every != "12h0m0s" {
+ t.Fatalf("declaration = %+v", d)
+ }
+ raw, _, _ := st.Get(ctx, DeclarationKey("bab", "app"))
+ var onDisk Declaration
+ if err := json.Unmarshal(raw, &onDisk); err != nil || len(onDisk.Schedules) != 2 {
+ t.Fatalf("on disk: %s %v", raw, err)
+ }
+ // The cap.
+ for i := 0; i < MaxSchedulesPerApp; i++ {
+ UpsertSchedule(ctx, st, "bab", "app", Schedule{Name: "n" + string(rune('a'+i)), Every: "1h", Path: "/p"}, now)
+ }
+ if _, err := UpsertSchedule(ctx, st, "bab", "app", Schedule{Name: "one-too-many", Every: "1h", Path: "/p"}, now); err == nil {
+ t.Fatal("cap not enforced")
+ }
+ d, present, err := RemoveSchedule(ctx, st, "bab", "app", "alpha", now)
+ if err != nil || !present {
+ t.Fatalf("remove: %v present=%v", err, present)
+ }
+ if _, ok := d.Find("alpha"); ok {
+ t.Fatal("alpha still declared")
+ }
+ if _, present, _ := RemoveSchedule(ctx, st, "bab", "app", "alpha", now); present {
+ t.Fatal("second remove reports present")
+ }
+}
+
+func TestReadDeclarationAbsentIsEmpty(t *testing.T) {
+ d, etag, err := ReadDeclaration(context.Background(), release.NewDirStore(t.TempDir()), "bab", "app")
+ if err != nil || etag != "" || len(d.Schedules) != 0 {
+ t.Fatalf("%+v %q %v", d, etag, err)
+ }
+}
+
+func TestTouchRunMovesTheEtag(t *testing.T) {
+ ctx := context.Background()
+ st := release.NewDirStore(t.TempDir())
+ if err := TouchRun(ctx, st, "bab", "app", "sync", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ _, e1, _ := st.Get(ctx, RunKey("bab", "app"))
+ if err := TouchRun(ctx, st, "bab", "app", "sync", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ _, e2, _ := st.Get(ctx, RunKey("bab", "app"))
+ if e1 == "" || e1 == e2 {
+ t.Fatalf("etag did not move: %q → %q", e1, e2)
+ }
+}
diff --git a/internal/schedule/spec.go b/internal/schedule/spec.go
new file mode 100644
index 000000000..7b439c95b
--- /dev/null
+++ b/internal/schedule/spec.go
@@ -0,0 +1,108 @@
+package schedule
+
+import (
+ "errors"
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const (
+ KindSchedule = "schedule"
+ KindOnce = "once"
+
+ MinEvery = time.Minute
+ MaxEvery = 720 * time.Hour
+ MaxOnceAhead = 400 * 24 * time.Hour
+
+ // MaxOncePerHost bounds how many pending "at" jobs a single box may
+ // hold at once — a later task's (the runner's) enforcement point, kept
+ // here with the other schedule-wide limits.
+ MaxOncePerHost = 1000
+)
+
+// Spec is the parsed timers.spec column — one of three shapes, and the only
+// thing the runner needs to know to compute the next fire.
+type Spec struct {
+ Kind string
+ Every time.Duration
+ Cron *CronExpr
+ At time.Time
+}
+
+// ParseSpec reads the canonical column text: "every <duration>",
+// "cron <5 fields>", "at <unix>". String is its inverse.
+func ParseSpec(s string) (Spec, error) {
+ kind, rest, _ := strings.Cut(strings.TrimSpace(s), " ")
+ rest = strings.TrimSpace(rest)
+ switch kind {
+ case "every":
+ d, err := time.ParseDuration(rest)
+ if err != nil {
+ return Spec{}, fmt.Errorf("every: %w", err)
+ }
+ if err := ValidEvery(d); err != nil {
+ return Spec{}, err
+ }
+ return Spec{Kind: KindSchedule, Every: d}, nil
+ case "cron":
+ c, err := ParseCron(rest)
+ if err != nil {
+ return Spec{}, err
+ }
+ return Spec{Kind: KindSchedule, Cron: c}, nil
+ case "at":
+ n, err := strconv.ParseInt(rest, 10, 64)
+ if err != nil {
+ return Spec{}, fmt.Errorf("at: %q is not a unix time", rest)
+ }
+ return Spec{Kind: KindOnce, At: time.Unix(n, 0).UTC()}, nil
+ }
+ return Spec{}, fmt.Errorf("spec %q: want every|cron|at", s)
+}
+
+// ValidEvery is the member-facing bound: whole minutes, 1m to 30d.
+func ValidEvery(d time.Duration) error {
+ switch {
+ case d < MinEvery:
+ return errors.New("every: shortest interval is 1m")
+ case d > MaxEvery:
+ return errors.New("every: longest interval is 720h (30 days)")
+ case d%time.Minute != 0:
+ return errors.New("every: whole minutes only")
+ }
+ return nil
+}
+
+func (s Spec) String() string {
+ switch {
+ case s.Every > 0:
+ return "every " + s.Every.String()
+ case s.Cron != nil:
+ return "cron " + s.Cron.String()
+ default:
+ return "at " + strconv.FormatInt(s.At.Unix(), 10)
+ }
+}
+
+// Next is the first fire strictly after now — epoch-aligned for every,
+// minute-stepped for cron, the instant itself for a pending once and the
+// zero time for a once that has passed. Always from now, never from the
+// scheduled time: that is what coalesces a missed run into one (spec §6.1).
+func (s Spec) Next(now time.Time) time.Time {
+ now = now.UTC()
+ switch {
+ case s.Every > 0:
+ n := now.Unix()
+ e := int64(s.Every / time.Second)
+ return time.Unix((n/e+1)*e, 0).UTC()
+ case s.Cron != nil:
+ return s.Cron.Next(now)
+ default:
+ if !s.At.After(now) {
+ return time.Time{}
+ }
+ return s.At.UTC()
+ }
+}
diff --git a/internal/schedule/spec_test.go b/internal/schedule/spec_test.go
new file mode 100644
index 000000000..20bf8268a
--- /dev/null
+++ b/internal/schedule/spec_test.go
@@ -0,0 +1,60 @@
+package schedule
+
+import (
+ "testing"
+ "time"
+)
+
+func TestParseSpecRoundTrips(t *testing.T) {
+ for _, s := range []string{"every 6h0m0s", "cron 0 8 * * *", "at 1787004000"} {
+ sp, err := ParseSpec(s)
+ if err != nil {
+ t.Fatalf("%q: %v", s, err)
+ }
+ if got := sp.String(); got != s {
+ t.Errorf("%q → %q", s, got)
+ }
+ }
+ for _, bad := range []string{"", "every", "every 0s", "every 30s", "every 800h", "cron * *", "at x", "sometimes"} {
+ if _, err := ParseSpec(bad); err == nil {
+ t.Errorf("ParseSpec(%q) = ok, want error", bad)
+ }
+ }
+}
+
+// Epoch alignment (spec §6.1): "every 6h" fires at 00/06/12/18 UTC on every
+// box, whatever time the row was created, so there is no phase to persist
+// and nothing drifts across restarts.
+func TestEveryIsEpochAligned(t *testing.T) {
+ sp := Spec{Kind: KindSchedule, Every: 6 * time.Hour}
+ cases := map[string]string{
+ "2026-08-23T13:07:00Z": "2026-08-23T18:00:00Z",
+ "2026-08-23T18:00:00Z": "2026-08-24T00:00:00Z", // strictly after
+ "2026-08-23T23:59:59Z": "2026-08-24T00:00:00Z",
+ }
+ for now, want := range cases {
+ if got := sp.Next(at(now)); !got.Equal(at(want)) {
+ t.Errorf("Next(%s) = %s, want %s", now, got.Format(time.RFC3339), want)
+ }
+ }
+}
+
+// Coalescing (spec §6.1): Next is computed from NOW, so a box that was down
+// across several occurrences produces one next fire, not a burst.
+func TestNextNeverReturnsThePast(t *testing.T) {
+ sp := Spec{Kind: KindSchedule, Every: time.Hour}
+ now := at("2026-08-23T13:07:00Z")
+ if got := sp.Next(now); !got.After(now) {
+ t.Fatalf("Next(%s) = %s is not after now", now, got)
+ }
+}
+
+func TestOnceFiresOnceThenNever(t *testing.T) {
+ sp := Spec{Kind: KindOnce, At: at("2026-09-01T08:00:00Z")}
+ if got := sp.Next(at("2026-08-23T00:00:00Z")); !got.Equal(sp.At) {
+ t.Fatalf("before: %s", got)
+ }
+ if got := sp.Next(at("2026-09-01T08:00:00Z")); !got.IsZero() {
+ t.Fatalf("after firing: %s, want zero", got)
+ }
+}
diff --git a/internal/schedule/status.go b/internal/schedule/status.go
new file mode 100644
index 000000000..d691e4377
--- /dev/null
+++ b/internal/schedule/status.go
@@ -0,0 +1,108 @@
+package schedule
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "sort"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// TimerStatus is one row of a box's report, and one row of `carlos schedule
+// ls` — the same shape end to end so the member reads what the box knows.
+type TimerStatus struct {
+ Name string `json:"name"`
+ Kind string `json:"kind"`
+ Spec string `json:"spec"`
+ Path string `json:"path"`
+ NextAt int64 `json:"next_at"`
+ LastAt int64 `json:"last_at"`
+ LastStatus string `json:"last_status"`
+ LastMs int64 `json:"last_ms"`
+ Attempts int `json:"attempts"`
+}
+
+// Status is apps/<acct>/<app>/schedule-status/<host>.json (spec §4.3):
+// what THIS instance will do next and what happened last, written by the
+// box that holds it. Unsupported, when set, says why Timers is empty.
+type Status struct {
+ Schema int `json:"schema"`
+ Host string `json:"host"`
+ Box string `json:"box"`
+ ReportedAt string `json:"reported_at"`
+ Unsupported string `json:"unsupported"`
+ Timers []TimerStatus `json:"timers"`
+}
+
+// WriteStatus replaces the host's report. The store's Put is CAS-only, so
+// this reads the current etag first; a conflict (two writers for one host —
+// not a designed state) re-reads once. Never loops: a report is
+// best-effort and the next fire rewrites it anyway.
+func WriteStatus(ctx context.Context, s release.Store, acct, app string, st Status) error {
+ if err := checkIDs(acct, app); err != nil {
+ return err
+ }
+ st.Schema = 1
+ if st.Timers == nil {
+ st.Timers = []TimerStatus{}
+ }
+ raw, err := json.Marshal(st)
+ if err != nil {
+ return err
+ }
+ key := StatusKey(acct, app, st.Host)
+ for attempt := 0; attempt < 2; attempt++ {
+ _, etag, err := s.Get(ctx, key)
+ if errors.Is(err, os.ErrNotExist) {
+ etag = ""
+ } else if err != nil {
+ return err
+ }
+ if _, err := s.Put(ctx, key, raw, etag); err == nil {
+ return nil
+ } else if !errors.Is(err, release.ErrCASConflict) {
+ return err
+ }
+ }
+ return fmt.Errorf("status for %s: conflicting writer", st.Host)
+}
+
+// ListStatus reads every host's report for the app, sorted by host. An
+// unreadable object is skipped and its error joined into the return, with
+// the readable ones still returned — one bad report must not blank the list.
+func ListStatus(ctx context.Context, s release.Store, acct, app string) ([]Status, error) {
+ if err := checkIDs(acct, app); err != nil {
+ return nil, err
+ }
+ keys, err := s.List(ctx, StatusPrefix(acct, app))
+ if err != nil {
+ return nil, err
+ }
+ var out []Status
+ var errs []error
+ for _, k := range keys {
+ if !strings.HasSuffix(k, ".json") {
+ continue
+ }
+ raw, _, err := s.Get(ctx, k)
+ if err != nil {
+ errs = append(errs, err)
+ continue
+ }
+ var st Status
+ if err := json.Unmarshal(raw, &st); err != nil {
+ errs = append(errs, fmt.Errorf("%s: %w", k, err))
+ continue
+ }
+ if st.Timers == nil {
+ st.Timers = []TimerStatus{}
+ }
+ out = append(out, st)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].Host < out[j].Host })
+ return out, errors.Join(errs...)
+}
diff --git a/internal/schedule/status_test.go b/internal/schedule/status_test.go
new file mode 100644
index 000000000..e6d3c8e60
--- /dev/null
+++ b/internal/schedule/status_test.go
@@ -0,0 +1,37 @@
+package schedule
+
+import (
+ "context"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestWriteAndListStatus(t *testing.T) {
+ ctx := context.Background()
+ st := release.NewDirStore(t.TempDir())
+ b := Status{Host: "b.bab.oncarlos.com", Box: "box1", ReportedAt: "2026-08-23T22:00:00Z", Timers: []TimerStatus{{Name: "sync", Kind: KindSchedule, Spec: "every 6h0m0s", Path: "/jobs/sync", NextAt: 100}}}
+ a := Status{Host: "a.bab.oncarlos.com", Box: "box1", ReportedAt: "2026-08-23T22:00:00Z", Unsupported: "unit-backed instance"}
+ for _, s := range []Status{b, a, b} { // the rewrite must not conflict
+ if err := WriteStatus(ctx, st, "bab", "app", s); err != nil {
+ t.Fatal(err)
+ }
+ }
+ got, err := ListStatus(ctx, st, "bab", "app")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 2 || got[0].Host != a.Host || got[1].Timers[0].Name != "sync" || got[0].Schema != 1 {
+ t.Fatalf("list = %+v", got)
+ }
+ if got[0].Timers == nil {
+ t.Fatal("timers must marshal as [] not null — the CLI ranges over it")
+ }
+}
+
+func TestListStatusEmptyPrefix(t *testing.T) {
+ got, err := ListStatus(context.Background(), release.NewDirStore(t.TempDir()), "bab", "app")
+ if err != nil || len(got) != 0 {
+ t.Fatalf("%v %v", got, err)
+ }
+}