spec: scheduled work — recurring + one-shot jobs as a platform concern
Platform owns WHEN (schedules.json in the bucket, timers table on the box, a runner that wakes + POSTs a tick), the app owns WHAT. Admin token becomes agent-minted; control socket gains authenticated once-timer verbs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+532
−0
docs/superpowers/specs/2026-08-23-scheduled-work-design.md+532 −0
diff --git a/docs/superpowers/specs/2026-08-23-scheduled-work-design.md b/docs/superpowers/specs/2026-08-23-scheduled-work-design.md| new file mode 100644 |
| index 000000000..38aa39dfc |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-23-scheduled-work-design.md |
| @@ -0,0 +1,532 @@ |
| +# Scheduled work: recurring and one-shot jobs as a platform concern |
| + |
| +**Date:** 2026-08-23 |
| +**Status:** approved in principle (Paul, 2026-08-23: "plan, spec, and implement |
| +both … so that we have a full story for apps to run recurring jobs or scheduled |
| +jobs"); every decision below was made overnight without a ruling and is marked |
| +**(decision)** so it can be overturned cheaply. |
| + |
| +## 1. The problem |
| + |
| +A CARLOS instance hibernates. That is the whole economic model — five small |
| +boxes hold thousands of accounts because most of them are asleep — and it |
| +means an app cannot rely on an in-process timer: the process is not there to |
| +fire it. |
| + |
| +The Eleven answer was ported with the activator (`internal/activator`, |
| +package comment): at sleep the agent asks the instance `GET /api/next-due` |
| +over its socket, records the answer in `routes.next_due`, and the sweeper |
| +wakes the instance when the time comes. Seapointish implements it today |
| +(hourly top-of-hour syncs). It works, and it leaves three things on every |
| +app: |
| + |
| +1. **Every app reimplements the scheduler.** `/api/next-due`, a table of |
| + due times, an awake-loop that fires them, a boot pass that catches up. |
| + Seapointish's `internal/syncer` is ~100 lines of exactly this; the |
| + birthday-alarm app would be another copy. |
| +2. **The wake is the whole contract.** The platform wakes the process and |
| + hopes its scheduler notices. Nothing tells the app *why* it was woken. |
| +3. **Nothing mints the secret the probe needs.** `backend_exec.NextDue` |
| + presents `CARLOS_ADMIN_TOKEN` from `<DataDir>/<host>.env` — and nothing in |
| + the platform writes that file. Console-created instances have no token, so |
| + the probe fails and the sleep path records "none" (`activator.go` stop |
| + path: "a missed probe must degrade to pre-index behavior"). The due-time |
| + index only ever worked for instances Eleven's tooling created. |
| + |
| +The platform design already names the direction (`2026-08-01-carlos-platform- |
| +design.md` §5: wake sources are delivery, **schedule** — "timers registered |
| +with the host agent before sleeping, or a cron line in the manifest" — and |
| +operator command). This spec fills that gap. |
| + |
| +Two shapes drove it: |
| + |
| +- **Seapointish**: sync every connector every 6 hours. A recurring job, |
| + identical for every instance of the app. |
| +- **Birthday alarm** (Paul's next app): email a reminder 14, 12, 10 … days |
| + before each birthday. Per-instance times that come from the app's own |
| + data. |
| + |
| +## 2. The split: platform owns WHEN, app owns WHAT |
| + |
| +**(decision)** The platform delivers exactly one thing — a **tick**: an HTTP |
| +`POST` to a path the app declared, at the time it is due, waking the instance |
| +first if it is asleep and holding it awake while the handler runs. The app's |
| +handler does the work. Nothing else crosses the boundary: no payloads, no job |
| +state, no queue. |
| + |
| +Two sources of "when", one delivery path: |
| + |
| +| Source | Who declares | Where it lives | Example | |
| +|---|---|---|---| |
| +| **Schedule** (recurring) | the member, `carlos schedule set` | `apps/<acct>/<app>/schedules.json` in the bucket → every instance of the app | seapointish `sync` every 6h | |
| +| **Once** (one-shot) | the running instance, over its control socket | the box registry's `timers` table, per instance | birthday reminder at 2026-09-01T08:00Z | |
| + |
| +What is deliberately **not** built **(decision)**: a job store — payloads |
| +posted by instances, per-job status and retries, dead-lettering, a jobs |
| +dashboard. That is a queue product; it duplicates what every app already has |
| +in SQLite; and it would make the control bucket the owner of app data. The |
| +tick shape gets the value (durable timing, no in-app scheduler, works while |
| +hibernated, one `carlos schedule` verb for visibility) without it. |
| + |
| +The birthday app is worth being explicit about, because it is the case that |
| +looks like it wants one-shots and mostly does not: a daily schedule at 08:00 |
| +UTC hitting `/jobs/reminders`, whose handler asks its own database "which |
| +reminders fall today?", is simpler and more robust than registering one |
| +once-timer per (person, offset). Once-timers are for the case that genuinely |
| +needs a specific time — "send this at 14:32" — and for apps that would rather |
| +not run a daily catch-all. |
| + |
| +## 3. Vocabulary |
| + |
| +- **schedule** — a named, recurring declaration on an app: `name`, `every` or |
| + `cron`, `path`. Applies to every instance of the app. |
| +- **once** — a named one-shot timer registered by one instance for itself: |
| + `name`, `at`, `path`. |
| +- **timer** — a row in the box registry: the materialized (host, name) pair |
| + of either kind, carrying `next_at`, `last_at`, `last_status`. |
| +- **tick** — the delivered request. |
| +- **fire** — one delivery attempt of one timer. |
| + |
| +Names are `^[a-z0-9][a-z0-9-]{0,31}$` — one namespace per instance across |
| +both kinds, so a once-timer cannot shadow a schedule (§7.2 refuses it). |
| + |
| +## 4. Bucket objects |
| + |
| +All three are new keys under the existing app prefix, unsigned, purely |
| +additive — nothing that parses the app prefix today parses them, and an app |
| +that never uses schedules never has them. Absence is legal forever. |
| + |
| +### 4.1 `apps/<acct>/<app>/schedules.json` — the declaration |
| + |
| +```json |
| +{ |
| + "schema": 1, |
| + "updated_at": "2026-08-23T22:14:09Z", |
| + "schedules": [ |
| + {"name": "sync", "every": "6h", "path": "/jobs/sync"}, |
| + {"name": "reminders", "cron": "0 8 * * *", "path": "/jobs/reminders"} |
| + ] |
| +} |
| +``` |
| + |
| +CAS-guarded (read etag → write with if-match; a conflict is retried by the |
| +console door once, then reported). Written only by the console doors (§8). |
| +Sorted by name on write so byte-identical content means identical intent. |
| + |
| +Validation (`internal/schedule`, shared by console and CLI so a typo costs a |
| +round trip nothing): |
| + |
| +- `name`: as §3. |
| +- exactly one of `every` / `cron`. |
| +- `every`: a Go duration, whole minutes, **1m ≤ every ≤ 30d** (`720h`). |
| +- `cron`: five fields `min hour dom month dow`, UTC, supporting `*`, `n`, |
| + `a-b`, `*/n`, `a-b/n`, `a,b,c`; dow `0-6` (0 = Sunday, 7 accepted as 0); |
| + no names, no `@daily` aliases, no seconds field. Parser is ours |
| + (`GOPROXY=off` — no new dependency) and small. |
| +- `path`: absolute (`/…`), no `..` segment, no scheme/host, ≤ 256 bytes. |
| +- at most **20** schedules per app. |
| + |
| +### 4.2 `apps/<acct>/<app>/schedule-run.json` — "fire it now" |
| + |
| +Same shape and mechanism as `restart-gen.json` (`release/restartgen.go`): a |
| +touch object whose **etag movement** is the signal, never its content. |
| + |
| +```json |
| +{"schema": 1, "name": "sync", "touched_at": "…", "nonce": "…"} |
| +``` |
| + |
| +`carlos schedule run -name sync` touches it and bumps the serial; every box |
| +serving the app sees the etag move on its next fast tick and sets that |
| +schedule's `next_at = now` on each of its instances (§6.3). A CAS conflict on |
| +the touch is success (the signal already fired), exactly as restart-gen rules. |
| + |
| +### 4.3 `apps/<acct>/<app>/schedule-status/<host>.json` — the box reports back |
| + |
| +Written by the **box**, best-effort, after every fire and after every |
| +converge that changed the host's rows: |
| + |
| +```json |
| +{ |
| + "schema": 1, |
| + "host": "acme.bdn.oncarlos.com", |
| + "box": "carlos-ie-1", |
| + "reported_at": "2026-08-23T22:20:01Z", |
| + "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} |
| + ] |
| +} |
| +``` |
| + |
| +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 |
| +to do next and what happened last time. `unsupported` is set, with `timers` |
| +empty, for an instance this box cannot deliver to (§6.5) — so "nothing is |
| +happening" is a sentence, not a silence. |
| + |
| +**Consumers that parse the app prefix** (CLAUDE.md rule, checked while |
| +designing): `release.AppHasQualifiedObjects` only counts keys; |
| +`economicspass` only sizes them; provisioning lists `instances/` by its own |
| +prefix; deletion removes the whole prefix, which is the right thing for all |
| +three keys. No parser of the app prefix has to learn these shapes. |
| + |
| +## 5. Box state: the `timers` table |
| + |
| +New table in the box registry (`internal/registry`), alongside `routes`, |
| +`aliases`, `wildcard_domains`. Additive migration. |
| + |
| +```sql |
| +CREATE TABLE IF NOT EXISTS timers ( |
| + host TEXT NOT NULL, -- routes.host |
| + name TEXT NOT NULL, |
| + kind TEXT NOT NULL, -- 'schedule' | 'once' |
| + spec TEXT NOT NULL, -- 'every 6h' | 'cron 0 8 * * *' | 'at 1787004000' |
| + path TEXT NOT NULL, |
| + next_at INTEGER NOT NULL, -- unix; 0 = done (once) / never (unsupported) |
| + last_at INTEGER NOT NULL DEFAULT 0, |
| + last_status TEXT NOT NULL DEFAULT '', |
| + last_ms INTEGER NOT NULL DEFAULT 0, |
| + attempts INTEGER NOT NULL DEFAULT 0, -- consecutive failed fires of the current occurrence |
| + created_at INTEGER NOT NULL, |
| + PRIMARY KEY (host, name) |
| +); |
| +CREATE INDEX IF NOT EXISTS timers_due ON timers (next_at) WHERE next_at > 0; |
| +``` |
| + |
| +Registry methods: `UpsertTimer`, `DeleteTimer`, `TimersFor(host)`, |
| +`DueTimers(now, limit)`, `PruneTimers(hostsAlive, doneBefore)`, |
| +`FireTimerNow(host, name)`, `MarkTimerFired(host, name, at, status, ms, |
| +attempts, next)`. `Fingerprint()` is **not** extended — timers change nothing |
| +about routing, and the edge has no reason to reload for one (same reasoning |
| +as `Caps`). |
| + |
| +**Why the registry and not the bucket (decision):** the runner polls it every |
| +15s and updates it on every fire; that is a local SQLite's job, not an object |
| +store's. Declared schedules re-derive from the bucket on every converge, so |
| +they are self-healing if a box is rebuilt. Once-timers are **box-local**: if |
| +the box that holds an instance is lost, its once-timers go with it. That is |
| +the same durability the instance's own database has (restored from the |
| +replica, up to the checkpoint) and the app contract says so plainly: **an app |
| +re-asserts its once-timers at boot** (upsert by name is idempotent — §7.2). |
| +Instance moves between boxes are not live today; when they are, the timers |
| +rows travel with the route in the same step, and this is the seam. |
| + |
| +## 6. The box side: `internal/schedule` |
| + |
| +One new package; three parts. |
| + |
| +### 6.1 `Next` — when |
| + |
| +```go |
| +type Spec struct { Kind string; Every time.Duration; Cron *CronExpr; At time.Time } |
| +func ParseSpec(s string) (Spec, error) // the timers.spec column |
| +func (s Spec) Next(now time.Time) time.Time // zero time = never (once fired) |
| +``` |
| + |
| +- **`every` is epoch-aligned (decision):** `next = floor(now/every)*every + |
| + every`. A 6h schedule fires at 00/06/12/18 UTC on every instance on every |
| + box, with no phase to persist, no drift across restarts, and predictable |
| + output in `carlos schedule ls`. An interval that does not divide a day is |
| + still deterministic. |
| +- **`cron` is UTC only (decision).** Timezones and DST are a member-facing |
| + feature for a later ruling; a UTC cron line never silently double-fires or |
| + skips. |
| +- **Coalescing is structural:** `Next` is always computed from *now*, never |
| + from the scheduled time. A box down for six hours across an hourly |
| + schedule fires **once** on recovery, not six times. |
| + |
| +### 6.2 `Converge` — declaration → rows |
| + |
| +`schedule.Converge(ctx, reg, store, now, report)` runs as a leg of the |
| +existing pass set: on every serial move (`fastTick`, after `restartLeg` — |
| +same ordering reason: a bump can carry a promote and a schedule together, and |
| +rows must land after the config they may depend on), on the hourly |
| +`adoptLoop`, and once at agent start so rows exist after a reboot before any |
| +serial moves (the `restartSeed` precedent). |
| + |
| +For each `(account, app)` group among the registry's `Kind == "instance"` |
| +routes: |
| + |
| +1. Read `schedules.json` (etag cached per app; a miss is the empty |
| + declaration; a read error skips the app this pass with one log line and |
| + leaves its rows alone — a baseline with holes manufactures deletions, the |
| + restart pass's rule). |
| +2. For each exec-backed route of the app (§6.5 for the rest): upsert one |
| + `kind='schedule'` row per declared schedule. A **new** row gets `next_at |
| + = Next(now)`. An existing row whose `spec` or `path` changed gets the new |
| + values and `next_at = Next(now)`; an unchanged row is left alone (its |
| + `next_at`, `last_*`, `attempts` are the runner's). |
| +3. Delete `kind='schedule'` rows for names no longer declared. Never touch |
| + `kind='once'` rows here. |
| +4. Read `schedule-run.json` (etag cached); on movement, `FireTimerNow` for |
| + the named schedule on every route of the app. A name that is not declared |
| + is logged and ignored. |
| +5. `PruneTimers`: rows whose host is no longer in the registry (route |
| + removed) and `once` rows done (`next_at = 0`) for more than 7 days. |
| +6. For every host whose rows changed, `report(host)` — write §4.3. |
| + |
| +Converge is idempotent and die-free (one app's failure never aborts the |
| +pass), like `appconfig.Sync`. |
| + |
| +### 6.3 `Runner` — delivery |
| + |
| +A goroutine owned by the agent, ticking every **15s** (`fastTick` is 2s and |
| +does bucket reads; this does a local SQLite read — cheap enough for 15s and |
| +the lateness budget is "≤15s + wake"). |
| + |
| +``` |
| +DueTimers(now, 64) |
| + group by host; hosts run in parallel (≤4 at once), one timer at a time per host |
| + for each timer: |
| + deliver → outcome |
| + MarkTimerFired(...) // next_at, last_*, attempts |
| + emitEvent(app log) // "schedule sync fired POST /jobs/sync → 200 in 3.2s" |
| + report(host) // §4.3, best effort |
| +``` |
| + |
| +**Delivery** builds `POST <path>` with: |
| + |
| +| Header | Value | |
| +|---|---| |
| +| `Host` | the route host | |
| +| `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 | |
| +| `User-Agent` | `carlos-schedule/<build>` | |
| +| `Content-Length` | `0` | |
| + |
| +and sends it **through the activator** (`Service.ServeHTTP(rec, req, rt, |
| +proxy)` with a unix-socket proxy for the route's socket): a hibernating |
| +instance is woken by the same singleflight path a member request takes, the |
| +tick counts as activity (so the idle window starts *after* it), and the |
| +in-flight count holds the instance awake **for as long as the handler runs**. |
| +That last point is the contract the app relies on (§7.1). `ServeHTTP` |
| +returning `false` (route not hibernate-flagged) means the runner proxies |
| +straight to the socket, the edge's own fallback. |
| + |
| +**Why not through the public edge:** the edge does not strip `X-Carlos-*` |
| +request headers (the sidecar marker is deliberately spoofable-but-harmless), |
| +so the tick's authenticity cannot rest on a header — it rests on the bearer |
| +token, which only the agent and the instance hold. Going in-process also |
| +skips the cache, compression and usage accounting layers: a tenant is not |
| +billed wire bytes for its own tick, and a `POST` is never cache-served. |
| + |
| +**Outcomes (decision):** |
| + |
| +| Result | Status recorded | Then | |
| +|---|---|---| |
| +| 2xx | `"200"` | `attempts=0`; schedule → `next_at=Next(now)`; once → `next_at=0` (done) | |
| +| 3xx, 4xx other than 408/429 | `"404 (app refused)"` | **no retry** — the app has no such handler or rejected the token; `attempts=0`, schedule advances to `Next(now)`, once → done | |
| +| 5xx, 408, 429, wake failed, connect refused, sleeping-elsewhere | `"503: instance failed to wake"` | **retry**: `attempts++`, `next_at = now + min(1m·2^(attempts-1), 15m)`, capped at `Next(now)` for a schedule; after **5** attempts: status `"failed after 5 attempts: …"`, `attempts=0`, schedule advances, once → done | |
| +| handler ran past the **30-minute** delivery timeout | `"timeout after 30m"` | treated as delivered (no retry — the job may have completed or still be running); schedule advances, once → done | |
| + |
| +**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. |
| + |
| +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. |
| + |
| +### 6.4 The instance secret: `CARLOS_ADMIN_TOKEN` becomes agent-minted |
| + |
| +The tick's authenticity rests on the instance's own admin token, and today |
| +nothing mints it (§1). **(decision)** `ExecBackend.Start` (and `Seed`, which |
| +shares `instanceEnv`) ensures `<DataDir>/<host>.env` carries |
| +`CARLOS_ADMIN_TOKEN` before the child is spawned: absent, it mints 32 random |
| +bytes as hex and appends the line (file created `0600` if missing; an |
| +existing value is never touched or rotated). The overlay rule already makes |
| +this file win over bucket config (`mergeEnv`), so the token cannot be |
| +clobbered by `carlos env`. |
| + |
| +This also silently repairs the existing `/api/next-due` probe for every |
| +console-created instance — a bug fix riding along, noted in the PR. |
| + |
| +Rotation is not designed here; the token is a box-local secret with the |
| +lifetime of the instance's env file. |
| + |
| +### 6.5 What is not delivered to, and says so |
| + |
| +- **Unit-backed instances** (`Backing == BackingUnit`) run as a DynamicUser |
| + the agent cannot share a file with, have no `<host>.env` the agent owns, |
| + and no control socket (`backend_unit.go` header). v1 does not deliver to |
| + them **(decision)**; Converge writes a status object with `unsupported: |
| + "unit-backed instance — scheduled work delivers to exec-backed instances in |
| + this release"` and no rows. Follow-up: an agent-minted token delivered via |
| + the unit's config env, once unit tenants have a control channel story. |
| +- **Non-instance routes** (`Kind != "instance"`: static, addr-backed |
| + services, platform rows) are skipped silently; schedules are an app |
| + instance concept. |
| +- **Sidecar hosts** are exec children and simply work. |
| + |
| +## 7. The app contract |
| + |
| +### 7.1 Receiving a tick |
| + |
| +```go |
| +// POST /jobs/sync — a CARLOS scheduled tick. |
| +func handleSync(w http.ResponseWriter, r *http.Request) { |
| + if !carlosTick(r) { // bearer == CARLOS_ADMIN_TOKEN, constant-time |
| + http.Error(w, "forbidden", http.StatusForbidden) |
| + return |
| + } |
| + // Do the work HERE, synchronously. The platform holds the instance awake |
| + // for as long as this request is open (up to 30 minutes) and starts the |
| + // idle clock when it returns. Returning 202 and running in the background |
| + // means the instance may hibernate mid-job. |
| + if err := syncer.RunOnce(r.Context()); err != nil { |
| + http.Error(w, err.Error(), http.StatusInternalServerError) // 5xx = retry with backoff |
| + return |
| + } |
| + w.WriteHeader(http.StatusNoContent) |
| +} |
| +``` |
| + |
| +Rules the doc states plainly: |
| + |
| +- Verify `Authorization: Bearer <CARLOS_ADMIN_TOKEN>` with a constant-time |
| + 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. |
| +- 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. |
| + |
| +### 7.2 Registering a once-timer (control socket) |
| + |
| +The agent already hands exec children `CARLOS_CONTROL_SOCKET` (purge |
| +channel). **(decision)** The listener becomes the instance's general control |
| +channel: always bound for exec children (the env var is therefore always |
| +set), with purge mounted only when the box has a cache (`CARLOS_PURGE=header` |
| +keeps its "only when true" rule). |
| + |
| +| Verb | Body / result | |
| +|---|---| |
| +| `PUT /schedule/{name}` | `{"at": "2026-09-01T08:00:00Z", "path": "/jobs/remind"}` → `200` with the row. Upsert by name. `409` if `name` is a declared schedule. `at` up to 400 days ahead; a past `at` fires on the next runner tick. At most **1000** once-timers per host (`429`). | |
| +| `DELETE /schedule/{name}` | `204`, idempotent | |
| +| `GET /schedule` | this host's timers, both kinds (the §4.3 shape) | |
| + |
| +**Auth (decision):** every request carries `Authorization: Bearer |
| +<CARLOS_ADMIN_TOKEN>` — the same token the tick presents, so the app already |
| +has it. This is what closes the gap `purge.go`'s comment documents (a |
| +same-uid exec sibling can compute another host's control socket path): a |
| +sibling does not hold the other host's token. `POST /purge` keeps its |
| +existing unauthenticated behaviour — changing it would break every app |
| +purging today, and its blast radius is already bounded. |
| + |
| +The listener is constructed with the host and a token reader (closure over |
| +`<host>.env`), so the host is fixed at construction and never taken from a |
| +request — the confused-deputy guarantee `ListenPurge` already gives. |
| + |
| +### 7.3 The legacy `/api/next-due` pull |
| + |
| +Untouched. Seapointish keeps working exactly as today; the doc marks it as |
| +the pre-schedule contract and shows the migration: replace `syncer.Loop` + |
| +`/api/next-due` with `carlos schedule set -name sync -every 6h -path |
| +/jobs/sync` and a `POST /jobs/sync` handler that calls `RunOnce`. (That |
| +change lives in the seapointish repo; it is a follow-up, with the snippet |
| +above as its brief.) A framework helper in rastrillo (`carlos.Tick(r)` / |
| +`carlos.ScheduleAt(ctx, name, at, path)`) is a second follow-up. |
| + |
| +## 8. The console doors |
| + |
| +All four behind `apiAccountApp` (one byte-identical 404 for unknown account, |
| +unknown app, someone else's account), role `svcrole.Operate` (the `restart` |
| +class: operating a running app, not publishing a release), mutating doors |
| +through `allowWrite`, and every write followed by `bumpSerial` so boxes act in |
| +seconds. |
| + |
| +| Door | Does | |
| +|---|---| |
| +| `GET /api/cli/apps/{account}/{app}/schedules` | `{schedules: [...], instances: [{host, box, reported_at, unsupported, timers: [...]}]}` — the declaration plus every status object under `schedule-status/` | |
| +| `POST /api/cli/apps/{account}/{app}/schedules` | body `{name, every?, cron?, path}` → validate → CAS upsert into `schedules.json` → bump → returns the declaration | |
| +| `POST /api/cli/apps/{account}/{app}/schedules/{name}/delete` | CAS remove → bump → returns the declaration (`404` for an undeclared name is the byte-identical one — a name is not an account, but a probe is a probe) | |
| +| `POST /api/cli/apps/{account}/{app}/schedules/{name}/run` | `404` unless declared; touch `schedule-run.json` → bump → `{requested: true}` | |
| + |
| +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. |
| + |
| +## 9. The CLI: `carlos schedule` |
| + |
| +API mode only, `restart`'s reasoning verbatim: the writes are console writes |
| +that mean nothing without a fleet reading them. |
| + |
| +``` |
| +carlos schedule ls -app <a> |
| +carlos schedule set -app <a> -name sync -every 6h -path /jobs/sync |
| +carlos schedule set -app <a> -name reminders -cron "0 8 * * *" -path /jobs/reminders |
| +carlos schedule rm -app <a> -name sync |
| +carlos schedule run -app <a> -name sync |
| +``` |
| + |
| +`ls` prints the declaration, then one block per instance from the status |
| +objects — `next` and `last` as relative times, `last_status` verbatim, and |
| +`unsupported` as its own line. With no status objects yet: "no instance has |
| +reported yet — boxes report within seconds of a schedule landing". This is |
| +the no-infra verification loop: `set` → `ls` shows `next` per instance within |
| +seconds; `run` → `ls` shows `last` and `carlos logs -app` shows the fire. |
| + |
| +`set` validates client-side with the same `internal/schedule` functions the |
| +console uses, and prints the first `next` the schedule will fire at (computed |
| +locally — it is deterministic) so a cron typo shows up before the round |
| +trip. Output says **recorded**, not fired, for the reason `restart` says |
| +"requested". |
| + |
| +Usage printer gains one line under *Your apps*: |
| +`schedule recurring or one-shot work per app: ls | set | rm | run`. |
| + |
| +## 10. Security notes |
| + |
| +- **Nothing new crosses a trust boundary.** The tick is the agent calling the |
| + instance it already spawns, authenticated with a secret only those two |
| + hold. The control socket gets *stricter* (bearer auth on the new verbs). |
| +- **A forged `schedules.json`** (a bucket writer gone bad) can POST to an |
| + app's declared paths at most 20 schedules × once a minute. A bucket writer |
| + can already promote arbitrary releases to that app; this adds nothing. |
| +- **The console learns nothing new**: schedule names and paths are metadata |
| + of the same class as config var names. |
| +- **Ticks never carry app data** — no payloads in either direction beyond |
| + the HTTP status. |
| +- **Rate**: the runner's per-host serialization and the 1m floor on `every` |
| + bound the fire rate of any one instance; the 4-host parallelism bounds a |
| + box's concurrent wakes from timers. |
| + |
| +## 11. Rollout and verification |
| + |
| +Ordering is the usual: this lands in the box binary (agent) **and** the |
| +console. Neither half is dangerous alone: |
| + |
| +- A new agent on an old console: no `schedules.json` exists anywhere; |
| + Converge finds nothing; the runner has no rows. The only visible change is |
| + agent-minted admin tokens (§6.4) and the always-on control socket. |
| +- A new console on an old agent: `carlos schedule set` records the |
| + declaration and `ls` says no instance has reported. Boxes report once |
| + rolled. |
| + |
| +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. |
| +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. |
| +5. `carlos schedule rm -name ping` → `ls` shows it gone; no further fires. |
| + |
| +## 12. Out of scope (recorded so they are not re-derived) |
| + |
| +- Timezone-aware cron; `@daily`-style aliases. |
| +- Delivery to unit-backed tenants (§6.5). |
| +- Payloads, job history beyond `last_*`, retries beyond the fixed policy. |
| +- Multi-box ownership election (single owner per route today; the seam is |
| + the timers table travelling with the route). |
| +- Rastrillo helpers and the seapointish migration (follow-ups, §7.3). |
| +- Website + CLI release batch (standing rule: owed with the feature). |