carlos / platform Public

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>
Paul Campbell pushed by paul@keymail.dev a24f68941f17185c6d63e57fe0e795f6977f99c1 parent 1010be2
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).