carlos / platform Public

console/cli/spec: run wakes on the tick, 502 on an unlistable status prefix, doc defects

I4: the CLI header and `carlos schedule` usage, and handleAPIScheduleRun's
doc, said a hibernating instance is never woken by these verbs — the
opposite of what happens. Now: the console write wakes nothing; the box's
runner wakes the instance when the tick is due, within ~15s of a run.

I5: handleAPIScheduleList answers 502 "could not read instance reports —
try again" when ListStatus could not list at all (nil, err); a partial
result with a joined error is still 200 with what is knowable. Both
pinned.

I8: schedule.Mount's doc no longer claims the bearer closes the same-uid
sibling gap — purge.go's framing (other UNIX users + unauthenticated
dialers; the same-uid residual is reach, not privilege; per-instance uids
are the fix). Spec §8's sentence about svcroutes_test.go's door lists
replaced with what that test actually spells out.

Minors: purge_listen_windows.go names ListenControl's caller and the
CARLOS_CONTROL_SOCKET strip; spec §4.3 example gains due_at, §6.3/§7.1 say
precisely what X-Carlos-Schedule-At is and when it moves, §7 gains the
app-author notes (token in the environment, Host and X-Forwarded-*, a curl
for PUT /schedule, the deploy-straddle cut, once-timers at boot), §11 step
2 says what ls shows for an instance asleep since before the roll.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev e9f41e11fa8a1c49ff089fdeb8a401c16e5bbc49 parent f4a6389
6 files changed, +161 −23
  • docs/superpowers/specs/2026-08-23-scheduled-work-design.md +77 −10
  • internal/console/apischedule.go +16 −3
  • internal/console/apischedule_test.go +46 −0
  • internal/edge/purge_listen_windows.go +4 −2
  • internal/schedule/control.go +11 −4
  • schedule.go +7 −4
diff --git a/docs/superpowers/specs/2026-08-23-scheduled-work-design.md b/docs/superpowers/specs/2026-08-23-scheduled-work-design.md
index cf1622e1e..c35738c7c 100644
--- a/docs/superpowers/specs/2026-08-23-scheduled-work-design.md
+++ b/docs/superpowers/specs/2026-08-23-scheduled-work-design.md
@@ -154,11 +154,17 @@ converge that changed the host's rows:
"unsupported": "",
"timers": [
{"name": "sync", "kind": "schedule", "spec": "every 6h", "path": "/jobs/sync",
- "next_at": 1787004000, "last_at": 1786982400, "last_status": "200", "last_ms": 3210, "attempts": 0}
+ "next_at": 1787004000, "due_at": 1787004000,
+ "last_at": 1786982400, "last_status": "200", "last_ms": 3210, "attempts": 0}
]
}
```
+`due_at` is the occurrence `next_at` will deliver — the value the tick's
+`X-Carlos-Schedule-At` carries (§6.3). They differ after a retry (`next_at`
+is the retry instant, `due_at` still the occurrence being retried) and are
+`0` on a row a pre-`due_at` box wrote.
+
This is what makes `carlos schedule ls` honest for a member with no box
access (CLAUDE.md, "Operate via API and CLI"): the declaration alone says
what was asked; the status objects say what each instance is actually going
@@ -294,7 +300,7 @@ DueTimers(now, 64)
| `Authorization` | `Bearer <CARLOS_ADMIN_TOKEN>` — the instance's own token (§6.4) |
| `X-Carlos-Schedule` | the timer name |
| `X-Carlos-Schedule-Kind` | `schedule` / `once` |
-| `X-Carlos-Schedule-At` | the unix time this occurrence was due |
+| `X-Carlos-Schedule-At` | the **occurrence**: the row's `due_at` — the natural instant a schedule was due, the `at` of a once-timer, or the moment of a `carlos schedule run`. Identical on every retry of that occurrence; moves only when the row advances. (Falls back to `next_at` on a row written before `due_at` existed.) |
| `User-Agent` | `carlos-schedule/<build>` |
| `Content-Length` | `0` |
@@ -325,10 +331,21 @@ billed wire bytes for its own tick, and a `POST` is never cache-served.
**At-least-once** is the guarantee: a box that dies between wake and tick
still has the row due, and fires it again on recovery. The handler must be
-idempotent for its occurrence (`X-Carlos-Schedule-At` is the key an app
-should dedupe on if it cares). Nothing is ever fired twice *concurrently* for
-one host: the runner serializes per host and marks the row before taking the
-next.
+idempotent for its occurrence, and `X-Carlos-Schedule-At` is that
+occurrence's key: the box stores it as `due_at`, set when a row is created
+or its spec/path changes (`= next_at`), when a fire advances the row (`=`
+the new `next_at`), and by `run` (`= now`); a **retry keeps it** — only
+`next_at` moves to the retry instant. So the five attempts of one failed
+occurrence all carry the same value, and a redelivery after a box crash
+carries the value the first attempt did. Nothing is ever fired twice
+*concurrently* for one host: the runner keeps a per-host in-flight mark
+across ticks and skips a host whose delivery is still running, and marks the
+row before taking the next.
+
+A tick launches per-host work and returns; it never waits for a delivery.
+A 20-minute handler on one host holds only its own host's next rows, never
+another host's — the bound is `MaxParallelHosts` (4) deliveries in flight
+across the box, not per tick.
A timer whose `next_at` is in the past by more than one occurrence when the
runner sees it (box was down) fires **once** — §6.1's coalescing.
@@ -401,11 +418,39 @@ Rules the doc states plainly:
compare (seapointish's `platform.go` is the reference). A tick without a
valid token is an internet request to that path; treat it as one.
- Work inside the request. 2xx = done; 5xx = please retry; 4xx = don't.
-- Be idempotent per occurrence: `X-Carlos-Schedule-At` identifies it.
+- Be idempotent per occurrence: `X-Carlos-Schedule-At` identifies it, and
+ it is stable across retries — every retry of one failed occurrence
+ carries the same value, and a redelivery after a box crash carries the
+ value the first attempt did (§6.3). A schedule's value is the natural
+ instant it was due; a once-timer's is its `at`; a `carlos schedule run`
+ is its own occurrence, stamped with the moment of the run. If you record
+ which occurrences you have completed, key on this header, never on the
+ wall clock.
- The handler is an ordinary route; the app may also call it from a
"Sync now" button (with its own auth). Nothing about a tick is magic
beyond the headers.
+What else the app can rely on:
+
+- **The token is in the environment.** `CARLOS_ADMIN_TOKEN` is in the
+ instance's process environment (minted by the platform, §6.4); compare
+ the bearer against it. Never accept a tick on a header alone.
+- **The tick looks like a request from the edge.** `Host` is the route host
+ (`acme.bdn.oncarlos.com`), and the unix-socket proxy sets
+ `X-Forwarded-Host`/`X-Forwarded-Proto` as it does for a visitor. There is
+ no client address behind a tick. Nothing the edge would have added —
+ cache, compression, usage accounting — is in the path.
+- **A long job that straddles a deploy is cut, and re-run.** A deploy
+ cycles the instance after its converge grace (~90s); a tick still open
+ at that point is cut with the process, the runner sees a transport
+ error and retries — at-least-once, so the occurrence runs again. Jobs
+ longer than that should be resumable from the occurrence key.
+- **Once-timers survive a restart, and you may re-assert them at boot.**
+ They live in the box's registry, not the process, so a restart loses
+ none. An app that keeps its own record and re-registers on start can:
+ the control socket is bound BEFORE the spawn, so `PUT /schedule/{name}`
+ works from the first line of `main`.
+
### 7.2 Registering a once-timer (control socket)
The agent already hands exec children `CARLOS_CONTROL_SOCKET` (purge
@@ -420,6 +465,14 @@ keeps its "only when true" rule).
| `DELETE /schedule/{name}` | `204`, idempotent |
| `GET /schedule` | this host's timers, both kinds (the §4.3 shape) |
+From inside the instance, with nothing but curl:
+
+```sh
+curl --unix-socket "$CARLOS_CONTROL_SOCKET" -X PUT http://carlos/schedule/remind \
+ -H "Authorization: Bearer $CARLOS_ADMIN_TOKEN" \
+ -d '{"at":"2026-09-01T08:00:00Z","path":"/jobs/remind"}'
+```
+
**Auth (decision):** every request carries `Authorization: Bearer
<CARLOS_ADMIN_TOKEN>` — the same token the tick presents, so the app already
has it. Be precise about what that buys. The bearer closes the channel
@@ -474,8 +527,17 @@ seconds.
Audit lines: `schedule set <name>`, `schedule rm <name>`, `schedule run
<name>`. Store errors are logged, never returned (they carry bucket names).
-`svcroutes_test.go`'s door lists are extended — the new doors are Operate
-doors, and the test that pins the closed set must be told.
+The one exception in shape, not in rule: `GET …/schedules` answers 502 with
+a fixed sentence when the status prefix cannot be listed at all — a 200
+with `instances: []` there would read as "no instance has reported yet" for
+a bucket the console could not reach. Some reports plus an unreadable one is
+still 200 with what is knowable, the rest logged.
+
+`svcroutes_test.go` spells out only two lists: the doors a zero-role
+service principal may reach (the equality on the empty role set) and the
+families a service principal may never open. Operate doors join neither, so
+the new doors need no entry there — they are covered by the role gate
+itself.
## 9. The CLI: `carlos schedule`
@@ -547,7 +609,12 @@ Verified live, in this order, with zero box access:
1. `carlos schedule set -app <a> -name ping -every 1m -path /jobs/ping` on an
app whose handler exists (a test app on the flagship account).
-2. `carlos schedule ls` shows `next` per instance within ~5s.
+2. `carlos schedule ls` shows `next` per instance within ~5s — including an
+ instance that has been hibernating since before the roll: the converge
+ writes its rows and report without waking it, and its first tick mints
+ the admin token it never had (§6.4) and wakes it. Before that fix, such
+ an instance showed `last … → no instance token` five times and then a
+ `failed after 5 attempts` line, and was never woken.
3. `carlos logs -app <a> -f` shows `schedule ping fired POST /jobs/ping → 204`
within the minute; the instance was asleep when it fired.
4. `carlos schedule run -name ping` → a fire within ~15s.
diff --git a/internal/console/apischedule.go b/internal/console/apischedule.go
index f6d22c723..4a7ee8cd0 100644
--- a/internal/console/apischedule.go
+++ b/internal/console/apischedule.go
@@ -149,6 +149,13 @@ func (s *Server) declaredSchedule(ctx context.Context, w http.ResponseWriter, r
// the other way round: it is the answer, not a contribution to it, so a store
// error there is a 502.
//
+// The line between those two is whether ListStatus could LIST at all. A
+// failed listing comes back as (nil, err) and means nothing is knowable —
+// answering 200 with `instances: []` there would print "no instance has
+// reported yet" for a bucket the console could not reach, and a member would
+// read a broken console as a fleet that has not caught up. That is a 502.
+// Some reports plus a joined error is the partial case above.
+//
// No write budget and no audit line: it is a read.
func (s *Server) handleAPIScheduleList(w http.ResponseWriter, r *http.Request) {
_, acct, app, ok := s.apiScheduleApp(w, r, false, "this console cannot read schedules")
@@ -166,6 +173,10 @@ func (s *Server) handleAPIScheduleList(w http.ResponseWriter, r *http.Request) {
insts, err := schedule.ListStatus(ctx, s.cfg.Releases, acct.Sqid, app)
if err != nil {
log.Printf("console: api schedule status %s/%s: %v", acct.Sqid, app, err)
+ if insts == nil {
+ jsonError(w, http.StatusBadGateway, "could not read instance reports — try again")
+ return
+ }
}
writeJSON(w, http.StatusOK, scheduleAnswer(acct.Sqid, app, d, insts))
}
@@ -256,9 +267,11 @@ func (s *Server) handleAPIScheduleRemove(w http.ResponseWriter, r *http.Request)
// then announce — the same mechanism and the same rules as restart-gen.
//
// It answers "requested", not "fired", for restart's reason: what lands here
-// is a request in a bucket. Whether a given instance fires it depends on that
-// box seeing the serial move, and on the instance being awake — hibernating
-// ones are never woken by this.
+// is a request in a bucket. This write wakes nothing itself; each box's
+// converger marks the row due when it sees the serial move, and the box's
+// runner delivers on its next tick — waking a hibernating instance for it
+// the way a visitor would (spec §6.3) — so a fire follows within ~15s, and
+// `carlos logs` is where it shows.
func (s *Server) handleAPIScheduleRun(w http.ResponseWriter, r *http.Request) {
addr, acct, app, ok := s.apiScheduleApp(w, r, true, "this console cannot request a run")
if !ok {
diff --git a/internal/console/apischedule_test.go b/internal/console/apischedule_test.go
index 601d8f09b..0ef43eb3e 100644
--- a/internal/console/apischedule_test.go
+++ b/internal/console/apischedule_test.go
@@ -417,3 +417,49 @@ func TestAPIScheduleWithoutABucketIs503(t *testing.T) {
}
}
}
+
+// scheduleListBrokenStore cannot LIST the status prefix at all: the shape a
+// bucket outage takes on the read door.
+type scheduleListBrokenStore struct{ release.Store }
+
+func (b scheduleListBrokenStore) List(context.Context, string) ([]string, error) {
+ return nil, errors.New(brokenStoreErr)
+}
+
+// A total failure to read the instance reports is a 502 with a fixed
+// sentence, never a 200 that reads as "no instance has reported yet": a
+// member with no box access would take a broken console for a fleet that
+// has not caught up. The bucket's own error text stays in the log.
+func TestAPIScheduleListTotalStatusFailureIs502(t *testing.T) {
+ fx, token := scheduleFixture(t, "code-schedule-ls-broken")
+ fx.srv.cfg.Releases = scheduleListBrokenStore{fx.rel}
+ status, raw := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules", token)
+ if status != http.StatusBadGateway {
+ t.Fatalf("list with an unlistable status prefix = %d %s, want 502", status, raw)
+ }
+ if !bytes.Contains([]byte(raw), []byte("could not read instance reports")) || bytes.Contains([]byte(raw), []byte("carlos-prod-secret-bucket")) {
+ t.Fatalf("502 body = %s, want the fixed sentence and none of the store's text", raw)
+ }
+}
+
+// One unreadable report among readable ones is the PARTIAL case: the door
+// answers 200 with what is knowable and logs the rest, so one box writing
+// rubbish never blanks the list for every other instance of the app.
+func TestAPIScheduleListPartialStatusFailureIs200(t *testing.T) {
+ fx, token := scheduleFixture(t, "code-schedule-ls-partial")
+ good := schedule.Status{Host: "good.bab.oncarlos.com", Box: "carlos-ie-1", ReportedAt: time.Now().UTC().Format(time.RFC3339)}
+ if err := schedule.WriteStatus(context.Background(), fx.rel, release.OpsAccount, "titogo", good); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.rel.Put(context.Background(), schedule.StatusKey(release.OpsAccount, "titogo", "bad.bab.oncarlos.com"), []byte("{not json"), ""); err != nil {
+ t.Fatal(err)
+ }
+ status, raw := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules", token)
+ if status != http.StatusOK {
+ t.Fatalf("list with one bad report = %d %s, want 200", status, raw)
+ }
+ res := decodeScheduleList(t, []byte(raw))
+ if len(res.Instances) != 1 || res.Instances[0].Host != "good.bab.oncarlos.com" {
+ t.Fatalf("instances = %+v, want the one readable report", res.Instances)
+ }
+}
diff --git a/internal/edge/purge_listen_windows.go b/internal/edge/purge_listen_windows.go
index 59b875fb8..cd213e987 100644
--- a/internal/edge/purge_listen_windows.go
+++ b/internal/edge/purge_listen_windows.go
@@ -15,8 +15,10 @@ import (
// a listener would bind and serve while the umask that makes it owner-only
// does not exist, quietly downgrading a security property into a no-op on
// the one platform nobody here can test. An error at the boundary keeps the
-// guarantee honest. ListenPurge's caller (internal/activator/backend_exec.go)
-// already treats a failure here as "no purge listener".
+// guarantee honest. The caller is ListenControl's, in
+// internal/activator/backend_exec.go, which treats a failure here as "no
+// control listener": the instance runs without its control verbs and
+// CARLOS_CONTROL_SOCKET is stripped from its environment.
func secureUnixListen(socketPath string) (net.Listener, error) {
return nil, errors.New("purge listeners need a unix socket with owner-only permissions; not supported on windows")
}
diff --git a/internal/schedule/control.go b/internal/schedule/control.go
index 7f37a1c2a..2828e5fcf 100644
--- a/internal/schedule/control.go
+++ b/internal/schedule/control.go
@@ -23,10 +23,17 @@ type TimerStore interface {
// Mount adds the once-timer verbs to an instance's control socket (spec
// §7.2). host is fixed here and never read from a request — the
-// confused-deputy guarantee edge.ListenPurge gives — and every verb needs
-// the host's own CARLOS_ADMIN_TOKEN as a bearer, which is what closes the
-// same-uid-sibling gap purge.go documents: a sibling can compute this
-// socket's path, but not this instance's token.
+// confused-deputy guarantee edge.ListenControl gives — and every verb needs
+// the host's own CARLOS_ADMIN_TOKEN as a bearer. Be precise about what that
+// bearer buys (internal/edge/purge.go has the long form): it closes the
+// channel against OTHER UNIX users on the box and against any
+// unauthenticated dialer — reaching the socket is no longer enough. It does
+// NOT close the same-uid sibling gap: every exec child shares the agent's
+// uid, the token file is 0600 owner-only, so a sibling can read another
+// host's token and present it here. That residual is bounded — a sibling
+// with host B's token could already dial B's own socket with it; these
+// verbs add reach (a platform wake plus a POST at a chosen time and path),
+// not privilege — and per-instance uids are the real fix.
func Mount(mux *http.ServeMux, host string, store TimerStore, token func() (string, error), now func() time.Time) {
authed := func(fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
diff --git a/schedule.go b/schedule.go
index f92b5d0a7..69e809b97 100644
--- a/schedule.go
+++ b/schedule.go
@@ -2,8 +2,10 @@
// front over internal/console/apischedule.go). Pull-based, restart's shape
// and reason: the console touches one small object (schedules.json, or the
// run-signal touch object beside it), and every box serving the app notices
-// within seconds and acts on its own. Nothing is pushed at a box, no
-// hibernating instance is woken by any of these four verbs.
+// within seconds and acts on its own. Nothing is pushed at a box: the
+// console write itself wakes nothing, and it is the box's own runner that
+// wakes a hibernating instance when its tick is due — within ~15s of a
+// `run`, or at the next natural occurrence of a schedule.
//
// The vocabulary follows restart's honesty rule on purpose: `set` says
// "recorded", `rm` says "removed", `run` says "requested" — never "fired"
@@ -42,8 +44,9 @@ const scheduleUsage = `usage: carlos schedule <ls|set|rm|run> [flags]
-console <origin> console to act through (default: the only logged-in one)
Signed-in only (carlos auth login): a schedule is a console-authorized write,
-the same shape as carlos restart — boxes act on it within seconds, and a
-hibernating instance is never woken by any of these four verbs.
+the same shape as carlos restart — boxes act on it within seconds. The write
+itself wakes nothing; a box's runner wakes a hibernating instance when its
+tick is due (within ~15s of a run, or at the schedule's next occurrence).
`
const scheduleLsUsage = `usage: carlos schedule ls -app <a> [-account <a>] [-account-id <id>] [-console <origin>]