diff --git a/docs/BOX-API-DESIGN-TIMERS.md b/docs/BOX-API-DESIGN-TIMERS.md new file mode 100644 index 0000000..2586f27 --- /dev/null +++ b/docs/BOX-API-DESIGN-TIMERS.md @@ -0,0 +1,848 @@ +# BOX-API-DESIGN-TIMERS.md: Timer & Job Endpoint Design + +**Status:** Design only. No implementation. +**Owner:** operator-main. Sanctioned direction from human 2026-10-04. +**Supersedes:** the endpoint sketches in `BOX-API-SPEC.md` §§ "Endpoints" +(which are kept as background; this doc is the buildable contract). + +## 1. Scope + +Design the request/response schemas and security model for the +agentic timer/job management plane on `box.muse-dev.online`: + +- `GET/POST/DELETE /api/box/timers` — systemd timer lifecycle +- `GET/POST/PUT/DELETE /api/box/jobs` — job definition lifecycle +- `POST /api/box/jobs/{name}/trigger` — manual job dispatch +- `GET/POST /api/box/requests` — the `[REQ]`/`[CONFIRM]` flow + +Out of scope: `/api/box/dms`, `/api/box/board/*`, `/api/box/chat/*`, +`/api/box/message` (covered by BOX-API-SPEC.md appendices B/C; they +reuse the auth and audit patterns defined here). + +## 2. Ground truths (read before implementing) + +Reality on 2026-10-04, verified live. The design conforms to these; +where BOX-API-SPEC.md v0.1 disagrees, this doc wins. + +1. **Job definitions are JSON, not YAML.** bl has Python 3.14.5 and no + PyYAML. Canonical path: `/home/super/Projects/NetVM/jobs/.json`. + The API accepts and returns `application/json` only. (A YAML + frontend may be added later with a vendored parser; it is not + part of v1.) +2. **The dispatcher is `bin/job-dispatch.py [--dry-run]`.** + It renders the prompt template, handles sidechat create/reuse + (via `job-sidechats.json`: `reuse_key → thread UUID`), sends via + `dm.py` / direct sidechat send, and logs to `job-log.jsonl`. +3. **Timers are systemd user units** at + `~/.config/systemd/user/job-.service` and + `~/.config/systemd/user/job-.timer`, owned by `super` on bl. + The service `ExecStart` calls `job-dispatch.py ` — nothing else. +4. **The API server is `/srv/board/server.py` on the VM** (port 8090, + behind Caddy; `box.muse-dev.online` routes to its box host branch). + New endpoints plug into the existing `_box_api` dispatch, next to + `/api/box/nodes`, `/api/box/chats`, etc. +5. **Auth already exists:** `_box_tier()` returns + `("operator", actor)` for PIN-session or operator bearer token, or + `("agent", ident)` for a valid box-signature + (`identity`+`ts`+`sig` query params, skew-checked). Anything else + gets 403. Audit helper `_box_audit(event, actor, tier, detail)` + appends to `/srv/box/audit.jsonl` + (`{ts, event, actor, tier, detail}`). +6. **VM → bl path exists:** `super@100.123.153.75` over tailnet, + passwordless SSH from the VM. This is the only channel the API + server uses to touch bl. + +## 3. Authorization model + +Timer management is **privileged**: creating a timer schedules future +code execution on shared infrastructure. The model follows the fleet +trust tiers (operator ≥ agent-identity > dev > anonymous). + +### 3.1 Matrix + +| Endpoint | super (PIN) | operator (bearer) | agent (box-sig, allowlisted identity) | dev tier | anonymous | +|---|---|---|---|---|---| +| `GET /timers`, `GET /timers/{name}` | ✅ | ✅ | ✅ | ❌ | 403 | +| `POST /timers` (create) | ✅ | ✅ | ✅ * | ❌ | 403 | +| `DELETE /timers/{name}` | ✅ | ✅ | ✅ * | ❌ | 403 | +| `POST /timers/{name}/{start,stop,enable,disable}` | ✅ | ✅ | ✅ * | ❌ | 403 | +| `GET /jobs`, `GET /jobs/{name}` | ✅ | ✅ | ✅ | ❌ | 403 | +| `POST /jobs`, `PUT /jobs/{name}` | ✅ | ✅ | ✅ * | ❌ | 403 | +| `DELETE /jobs/{name}` | ✅ | ✅ | ✅ * | ❌ | 403 | +| `POST /jobs/{name}/trigger` | ✅ | ✅ | ✅ * | ❌ | 403 | +| `GET /requests`, `GET /requests/{id}` | ✅ | ✅ | ✅ (own only) | ❌ | 403 | +| `POST /requests` | ✅ | ✅ | ✅ * | ❌ | 403 | + +`*` Agent box-signatures are accepted **only if the identity is in the +operator allowlist** (`/srv/board/allowed_signers` entries flagged +`box_mutate`). A box-signature from a non-operator agent (e.g. a dev +agent) is treated as read-capable only and gets 403 on mutations. +Rationale: box-signatures prove *which* agent, not *what tier* — the +allowlist binds identity to privilege. + +### 3.2 Why devs get nothing here + +Devs execute jobs; they do not schedule them (JOB-SPEC.md § Security: +"Agents cannot create jobs"). A dev that could create timers could +schedule DM spam or exfiltrate via prompt templates. Read access is +also withheld: timer definitions reveal fleet operational tempo. +The human may grant exceptions via the duties system; the API checks +the grant at request time, it is not hardcoded. + +### 3.3 What "operator" means for agents + +`operator-main`, `operator-646`, and the node agents (`muse`, `pip`, +`646`, `opm`) authenticate with box-signatures. Their tier for box +mutations comes from the allowlist, not the signature itself. The +human (super) uses the PIN session. There is no separate "admin" +tier — super is the trust root and can already do everything via +other channels. + +## 4. The constrained bl helper: `bin/box-ctl.py` + +**The board server never runs raw `systemctl` or shell over SSH.** +All bl mutations go through one allowlisted helper: + +``` +/home/super/Projects/NetVM/bin/box-ctl.py [args...] +``` + +Invoked from the VM as: + +``` +ssh -o BatchMode=yes super@100.123.153.75 \ + /home/super/Projects/NetVM/bin/box-ctl.py timer-status heartbeat +``` + +### 4.1 Why a helper instead of `systemctl` over SSH + +- BOX-API-SPEC.md proposed SSH with a restricted key. A restricted + key still permits `systemctl --user ` — including + `systemctl --user edit`, `link`, or `set-property` on attacker-named + units. An allowlist *inside* the helper is a smaller, auditable + surface: the helper accepts a fixed verb set and validates every + argument before acting. +- The helper also owns the **safe unit-file generation** + (template-only, no string interpolation of user content into unit + files) and the **cron→OnCalendar conversion**, keeping that logic + next to the dispatcher it serves. +- Defense in depth: the API server validates first (fast 400s), the + helper validates again (it is the trust boundary; the API server + is reachable from the internet via Caddy). + +### 4.2 Allowlisted actions + +| Action | Args | Effect | +|---|---|---| +| `timer-list` | — | JSON list of `job-*.timer` units + state | +| `timer-status` | `` | JSON: active/enabled/last/next/result | +| `timer-create` | `` | Read `jobs/.json`, write `.service`+`.timer` from template, `daemon-reload`, `enable --now` | +| `timer-delete` | ` [--keep-job]` | stop, disable, remove unit files, `daemon-reload` | +| `timer-start` / `timer-stop` / `timer-enable` / `timer-disable` | `` | the obvious systemctl op on `job-.timer` | +| `job-list` | — | JSON list of `jobs/*.json` (parsed summaries) | +| `job-get` | `` | print the job JSON | +| `job-put` | `` | read job JSON from **stdin**, schema-validate, write file, `git add`+`git commit` | +| `job-delete` | `` | `git rm` the job JSON + commit | +| `job-trigger` | `` | run `job-dispatch.py ` (foreground; caller decides sync/async) | + +The helper prints JSON to stdout (`{"ok": true, ...}` or +`{"ok": false, "error": "...", "code": "..."}`) and exits nonzero on +failure. It never prints secrets. It never invokes a shell. + +### 4.3 Validation inside the helper (non-bypassable) + +- `` must match `^[a-z0-9-]{1,64}$`. Anything else → error, + no action. (This kills path traversal: no `/`, no `..`.) +- Unit files are generated from a **fixed template**; the only + interpolated value is `` (already regex-validated). The + `ExecStart` is always the absolute path to `job-dispatch.py` plus + the name. User-controlled strings (prompt templates, schedules) + never appear in unit files. +- `job-put` validates the full job schema (§6) before writing, + including: `agent` ∈ known nodes, `schedule` parses as cron and + converts to a valid `OnCalendar` (checked with + `systemd-analyze calendar --iterations=2`), `prompt_template` + length ≤ 4000 chars, `timeout` 60–3600, `on_failure` ∈ + `{retry, alert, ignore}`. +- `timer-create` refuses if the job JSON is missing or invalid, and + refuses if a `job-.timer` already exists (use update flow). +- Every action appends to the bl-side log + (`/home/super/Projects/NetVM/box-ctl.jsonl`): + `{ts, action, name, caller}` — `caller` is passed by the API + server as `BOX_CALLER` env (the authenticated actor). + +### 4.4 SSH hardening (recommended, not load-bearing) + +- The VM→bl SSH uses `BatchMode=yes`, no agent forwarding, no X11. +- Optional hardening: a dedicated `authorized_keys` entry on bl + with `command="/home/super/Projects/NetVM/bin/box-ctl.py"` + for a `box-api` SSH key held on the VM, so even a compromised + board server process can only invoke the helper. The helper's + own validation remains the real boundary. + +## 5. Endpoint designs + +Base: `https://box.muse-dev.online/api/box`. +Auth: §3. All responses are JSON. Mutations are audit-logged +(`_box_audit("box.timer.create", actor, tier, {...})` etc.). + +Conventions used below: + +- `{name}` is the **job name** (`heartbeat`), not the unit name. + The API maps `name` → `job-.timer` internally. Clients + never construct unit names. +- `dry_run=true` query param: validate everything, report the + planned actions, change nothing. Returns `200` with + `"dry_run": true` and a `plan` array. +- `Idempotency-Key` header on POST: the server stores + `{key → response}` for 24h in `/srv/box/idempotency.jsonl`; + replays return the original response with `200` (not 201). +- Timestamps are UTC ISO-8601 (`2026-10-04T03:40:00Z`). + +### 5.1 Timers + +#### `GET /api/box/timers` + +List all job timers on bl. + +**Response `200`:** +```json +{ + "timers": [ + { + "name": "heartbeat", + "unit": "job-heartbeat.timer", + "active": true, + "enabled": true, + "last_run": "2026-10-04T03:40:00Z", + "next_run": "2026-10-04T03:45:00Z", + "last_result": "success", + "job_name": "heartbeat", + "schedule": "*/5 * * * *" + } + ] +} +``` + +- `last_result`: `success` | `failed` | `unknown`, derived from the + most recent `job_sent`/`job_result`/`job_timeout` entries for the + job in `job-log.jsonl`. +- Timers with no corresponding job JSON are listed with + `"orphan": true` (unit exists, definition missing) — never silently + dropped. + +**Errors:** `502` (`BL_UNREACHABLE`) if bl SSH fails. + +#### `GET /api/box/timers/{name}` + +Full status for one timer, including the parsed job definition. + +**Response `200`:** +```json +{ + "name": "heartbeat", + "unit": "job-heartbeat.timer", + "active": true, + "enabled": true, + "last_run": "2026-10-04T03:40:00Z", + "next_run": "2026-10-04T03:45:00Z", + "last_result": "success", + "oncalendar": "*:0/5", + "job_definition": { + "name": "heartbeat", + "agent": "opm", + "schedule": "*/5 * * * *", + "timeout": 300 + } +} +``` + +**Errors:** `404` (`NOT_FOUND`) — no such timer (or no such job). + +#### `POST /api/box/timers` + +Create a job **and** its timer atomically. Body is the job +definition JSON (§6). This is the primary creation path; it keeps +"job exists but timer missing" states unrepresentable. + +**Request:** +```http +POST /api/box/timers +Content-Type: application/json +Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000 + +{ + "name": "board-watch", + "description": "Check board for new posts every 5 minutes", + "schedule": "*/5 * * * *", + "agent": "muse", + "prompt_template": "Check the board for posts since {last_run}. ...", + "timeout": 300, + "on_failure": "alert", + "chain_next": null, + "sidechat": { "create": true, "name_template": "job-{job_name}-{date}", "reuse_key": "board-watch-muse" } +} +``` + +**Backend sequence** (each step validated before the next): +1. API server validates JSON schema + authz (§3). `400` on failure. +2. `box-ctl.py job-put ` (stdin = body) → writes + validates + on bl, git-commits. Failure → `400`/`502`, nothing else happens. +3. `box-ctl.py timer-create ` → writes units from template, + `daemon-reload`, `enable --now`. Failure → job JSON is kept + (it is valid and committed) and the response is `502` with + `"timer_created": false` so the client can retry creation + without resubmitting the job. +4. Audit log entry. Optional `[REQ]` confirmation DM (§8). + +**Response `201`:** +```json +{ + "name": "board-watch", + "unit": "job-board-watch.timer", + "created": true, + "timer_created": true, + "active": true, + "next_run": "2026-10-04T04:00:00Z", + "request_id": "a1b2c3d4" +} +``` + +**Errors:** +- `400` (`INVALID_JOB`) — schema validation failed; `detail` names + the offending field and reason. +- `409` (`ALREADY_EXISTS`) — job name or timer already exists. + The client should `PUT /jobs/{name}` or `DELETE` first. +- `502` (`BL_UNREACHABLE`, `TIMER_CREATE_FAILED`). + +#### `DELETE /api/box/timers/{name}` + +Stop, disable, and remove the timer. The job definition is kept by +default (timers are runtime state; definitions are config). + +**Query params:** `keep_job=true|false` (default `true`). + +**Backend:** `box-ctl.py timer-delete [--keep-job]`. + +**Response `200`:** +```json +{ + "name": "heartbeat", + "deleted": true, + "job_kept": true +} +``` + +**Errors:** `404` (`NOT_FOUND`), `502`. + +#### `POST /api/box/timers/{name}/start` (also `/stop`, `/enable`, `/disable`) + +Control a timer. One action per endpoint; the action is in the path, +not the body, so there is nothing to inject. + +**Response `200`:** +```json +{ + "name": "heartbeat", + "action": "stop", + "success": true, + "active": false, + "enabled": true +} +``` + +**Errors:** `404`, `502`. `POST .../start` on an already-active +timer is `200` with `"success": true` (idempotent). + +### 5.2 Jobs + +Jobs are definitions; timers are runtime. These endpoints manage +definitions **without** touching timers (use §5.1 to create both +together). + +#### `GET /api/box/jobs` + +**Response `200`:** +```json +{ + "jobs": [ + { + "name": "heartbeat", + "description": "Heartbeat job - verifies DM system ...", + "schedule": "*/5 * * * *", + "agent": "opm", + "has_timer": true, + "timer_active": true, + "timeout": 300 + } + ] +} +``` + +#### `GET /api/box/jobs/{name}` + +**Response `200`:** the full job JSON (`{"name": ..., "definition": +{...}}` — or the definition object directly; v1 uses +`{"name": "", "definition": {}}`). + +**Errors:** `404` (`NOT_FOUND`). + +#### `POST /api/box/jobs` + +Create (or replace, with `?replace=true`) a job definition **without** +creating a timer. For definitions that will be wired to a timer +later, or timerless jobs triggered manually / by chains. + +Same body schema as `POST /timers` (§6). Backend: `box-ctl.py job-put`. + +**Response `201`:** `{"name": "...", "created": true, "has_timer": false}`. +`409` without `?replace=true` if the job exists. + +#### `PUT /api/box/jobs/{name}` + +Replace the definition of an existing job. If a timer exists, its +schedule is regenerated from the new definition +(`timer-create` semantics on the existing unit: rewrite + reload). +The `{name}` in the path must equal `body.name` (`400` otherwise). + +**Response `200`:** +```json +{ + "name": "heartbeat", + "updated": true, + "timer_reloaded": true +} +``` + +#### `DELETE /api/box/jobs/{name}` + +Delete the definition. Refuses (`409` `TIMER_STILL_ACTIVE`) if a +timer exists unless `?force=true`, in which case the timer is +deleted first (same backend as `DELETE /timers/{name}?keep_job=false`). + +**Response `200`:** `{"name": "...", "deleted": true, "timer_deleted": false}`. + +#### `POST /api/box/jobs/{name}/trigger` + +Run the dispatcher **now**, outside the schedule. For testing and +for chain steps. + +**Backend:** `box-ctl.py job-trigger ` runs +`job-dispatch.py ` on bl. The API server runs it **in the +background** and returns immediately — dispatch involves browser +automation that can take 30–60s; the HTTP request must not block. + +**Response `202`:** +```json +{ + "name": "heartbeat", + "job_id": "heartbeat-20261004-034737-63b57a71", + "triggered": true, + "status_url": "/api/box/requests/" +} +``` + +The `job_id` is minted by the API server (same format as the +dispatcher: `{name}-{YYYYMMDD-HHMMSS}-{8hex}`) and passed to the +dispatcher via a `--job-id` flag (dispatcher change required; until +then the response omits `job_id` and only returns `request_id`). +Completion is tracked through the confirmation system (§8): the +trigger creates a request of type `job_trigger`, and the +`[RESULT job_id]` collector marks it confirmed. + +**Errors:** `404` (`NOT_FOUND` — no job definition), `502`. + +### 5.3 Requests (`[REQ]`/`[CONFIRM]`) + +Side-effecting API calls (§5.1–5.2 mutations, `trigger`) create a +**request** record. The server notifies the responsible agent with a +`[REQ {request_id}]` DM; the agent's `[CONFIRM {request_id}]` reply +(or the JOB system's `[RESULT {job_id}]`) closes the loop. This is +the generalization of `[JOB]`→`[RESULT]` from BOX-API-SPEC.md. + +#### Request record + +```json +{ + "request_id": "a1b2c3d4", + "type": "timer_create", + "status": "pending", + "actor": "operator-main", + "tier": "agent", + "job_name": "board-watch", + "created_at": "2026-10-04T04:00:00Z", + "confirmed_at": null, + "attempts": 1, + "timeout_s": 300, + "detail": { "unit": "job-board-watch.timer" }, + "confirm_payload": null +} +``` + +- `type`: `timer_create` | `timer_delete` | `timer_control` | + `job_create` | `job_update` | `job_delete` | `job_trigger` | + `generic`. +- `status`: `pending` → `confirmed` | `failed` | `timeout`. +- Store: `/srv/box/requests.jsonl` (append-only log) plus a + companion index `/srv/box/requests-index.json` + (`{request_id → latest status}`) rebuilt from the log on startup. + The log is the source of truth; the index is a cache. + +#### `POST /api/box/requests` + +Create a generic confirmation-tracked request (for flows the typed +endpoints don't cover). The server sends the `[REQ]` DM immediately. + +**Request:** +```json +{ + "type": "generic", + "title": "Rotate webhook secret", + "assignee": "opm", + "body": "Rotate the X secret and confirm.", + "timeout_s": 600 +} +``` + +**Response `201`:** +```json +{ + "request_id": "f7e8d9c0", + "status": "pending", + "dm_sent": true, + "status_url": "/api/box/requests/f7e8d9c0" +} +``` + +#### `GET /api/box/requests` + +List requests, newest first. Query params: +`status=pending|confirmed|failed|timeout`, +`actor=`, `type=`, `limit` (default 20, max 100). + +**Response `200`:** +```json +{ + "requests": [ + { + "request_id": "a1b2c3d4", + "type": "timer_create", + "status": "confirmed", + "actor": "operator-main", + "job_name": "board-watch", + "created_at": "2026-10-04T04:00:00Z", + "confirmed_at": "2026-10-04T04:00:22Z" + } + ] +} +``` + +Agents with box-signatures see only requests where they are the +actor or the assignee (privacy: one agent's pending work is not +another's business). Operators and super see all. + +#### `GET /api/box/requests/{request_id}` + +**Response `200`:** the full record (§5.3), including +`confirm_payload` when confirmed: + +```json +{ + "request_id": "a1b2c3d4", + "status": "confirmed", + "confirm_payload": { + "text": "Timer job-board-watch.timer created and active.", + "at": "2026-10-04T04:00:22Z" + } +} +``` + +**Errors:** `404` (`NOT_FOUND`), `403` (agent requesting +another agent's request). + +## 6. Job definition JSON schema (canonical) + +This is the contract for `POST /timers`, `POST /jobs`, +`PUT /jobs/{name}`, and what `GET /jobs/{name}` returns. + +```json +{ + "name": "board-watch", + "description": "Check board for new posts every 5 minutes", + "schedule": "*/5 * * * *", + "scheduler": "systemd", + "agent": "muse", + "prompt_template": "Check the board for posts since {last_run}. ...", + "timeout": 300, + "on_failure": "alert", + "chain_next": null, + "sidechat": { + "create": true, + "name_template": "job-{job_name}-{date}", + "reuse_key": "board-watch-muse" + } +} +``` + +| Field | Required | Rules | +|---|---|---| +| `name` | ✅ | `^[a-z0-9-]{1,64}$`. Immutable after creation. | +| `description` | ❌ | ≤ 280 chars. | +| `schedule` | ✅ | Cron `M H dom mon dow`. Each field validated (ranges, steps, lists). Must convert to a valid systemd `OnCalendar` (§7). | +| `scheduler` | ❌ | Default `"systemd"`. v1 accepts **only** `"systemd"`; `"cron"`/`"at"` return `400` `UNSUPPORTED_SCHEDULER` (appendix A of BOX-API-SPEC.md is v2). | +| `agent` | ✅ | ∈ `{"muse","pip","646","opm"}` — must be a node in the machine registry with a healthy chromebox. | +| `prompt_template` | ✅ | 1–4000 chars. May use `{job_id}`, `{job_name}`, `{datetime}`, `{date}`, `{last_run}`. Unknown `{placeholders}` → `400`. Must not contain `[REQ`/`[CONFIRM`/`[JOB`/`[RESULT` literals (those are protocol framing; the dispatcher adds them). | +| `timeout` | ❌ | Default 300. Integer, 60–3600. | +| `on_failure` | ❌ | Default `"alert"`. ∈ `{"retry","alert","ignore"}`. | +| `chain_next` | ❌ | Default `null`. If set, must name an existing job (`400` otherwise; prevents dangling chains). | +| `sidechat.create` | ❌ | Default `false`. Boolean. | +| `sidechat.name_template` | ❌ | Only meaningful if `create` is true. ≤ 120 chars; allowed placeholders `{job_name}`, `{date}`, `{job_id}`. | +| `sidechat.reuse_key` | ❌ | `^[a-z0-9-]{1,64}$`. Enables the `job-sidechats.json` UUID mapping so recurring jobs reuse one thread instead of spawning one per run. **Recommended for every recurring job.** | + +Validation happens twice: in the API server (fast, user-friendly +400s) and in `box-ctl.py job-put` (authoritative — §4.3). + +## 7. Cron → systemd OnCalendar conversion + +The helper converts the cron `schedule` to `OnCalendar=` when +generating the `.timer` unit. v1 supports the patterns jobs +actually use; anything else is rejected with a clear error +rather than silently mis-scheduled. + +| Cron | OnCalendar | +|---|---| +| `*/5 * * * *` | `*:0/5` | +| `*/15 * * * *` | `*:0/15` | +| `0 * * * *` | `hourly` | +| `0 */2 * * *` | `*:0/120` (see note) | +| `30 2 * * *` | `02:30` | +| `0 9 * * 1` | `Mon 09:00` | + +- Conversion is done by a small pure function in `box-ctl.py` + (no croniter — not installed on bl, and adding a dependency for + this is overkill). +- The generated string is validated with + `systemd-analyze calendar "" --iterations=2`; nonzero exit + → `400 INVALID_SCHEDULE`. +- Note on `*/2` hours: systemd `OnCalendar` supports `/` repetition + on the hour field (`0/2:00`); the converter emits that form. +- `Persistent=true` is always set (catch-up after downtime — + matches the existing heartbeat timer). +- `AccuracySec=1min` default (no need for sub-minute precision; + reduces wakeups). + +## 8. Confirmation flow mechanics + +### 8.1 Who sends the `[REQ]` DM + +The API server (VM), via a new box-ctl action or via the existing +DM path. Two options; the design picks (a): + +- (a) **The helper sends it.** New action `box-ctl.py notify + `: renders + `[REQ {request_id}] {title}\n{body}\nReply with [CONFIRM {request_id}] ...` + and sends via `dm.py` on bl (it is already there, next to the + dispatcher). Keeps DM-sending on bl where the chromeboxes live. +- (b) The VM sends via a future `/api/box/dms` endpoint. Deferred. + +The `[REQ]` DM goes to the **assignee**: for typed requests, the +agent that owns the job (`job.agent`); for generic requests, the +`assignee` field. If the request was made by an agent about its own +job, the `[REQ]` still goes out — the confirmation is about the +*action completing on bl*, not about the requester's intent. + +### 8.2 Who sends `[CONFIRM]` + +- For `timer_create/delete/control`, `job_create/update/delete`: + the confirmation is **server-side and automatic**. The helper + already verified the outcome (unit active, file written). The + request is marked `confirmed` when the helper reports success — + no agent round-trip needed. The `[REQ]` DM is then informational + ("this happened"), and the request record is the audit trail. + Rationale: the agent being asked to confirm is often the same + agent whose chromebox just did the work; a human-style + ack adds latency without signal. The spec's "DMs must result in + confirmations" is satisfied by the verified helper result + + audit log. +- For `job_trigger` and `POST /requests` (generic): confirmation + is **agent-driven**. The assignee replies `[CONFIRM {request_id}]` + (or `[RESULT {job_id}]` for triggers, which the result collector + translates). The pending-request sweeper (§8.3) handles silence. + +This split keeps the common path fast (verified writes need no +chat round-trip) while keeping genuinely asynchronous work +(job execution) explicitly confirmed. + +### 8.3 Pending-request sweeper + +A systemd timer on the VM (`box-request-sweeper`, every 60s) scans +`requests-index.json` for `pending` records where +`now - created_at > timeout_s` (default 300s): + +1. If `attempts < 3`: re-send the `[REQ]` DM (`attempts += 1`, + log `request_retry`). +2. Else: mark `timeout`, log `request_timeout`, and — if the + request type is `job_trigger` — apply the job's `on_failure` + policy (alert → DM to `#operators` / the requesting actor). + +The sweeper only touches records it owns (VM-side); it never +calls bl. + +### 8.4 Reading confirmations + +The result collector (JOB-SPEC.md §5, on bl) already watches for +`[RESULT {job_id}]`. It gains a second pattern: when a +`[CONFIRM {request_id}]` DM is seen in `dm-log.jsonl`, it appends +to a `confirmations.jsonl` which the VM sweeper (or a +`box-ctl.py confirm-pull` action) ships back to `/srv/box/`. +Simpler v1 alternative: the collector POSTs directly to the VM's +`POST /api/box/requests/{id}/confirm` (box-signed, operator +allowlist) — one new endpoint, no file shipping: + +```http +POST /api/box/requests/a1b2c3d4/confirm +Content-Type: application/json + +{ "ok": true, "text": "Timer job-board-watch.timer created and active." } +``` + +Response `200`: `{"request_id": "...", "status": "confirmed"}`. +`ok: false` marks `failed` with the text as reason. This endpoint +is allowlisted to the same identities as mutations (§3.1). + +## 9. Security details + +1. **No arbitrary command execution.** The API server's bl access + is exactly: `ssh … box-ctl.py `. + There is no endpoint that passes a shell string, no `ExecStart` + interpolation, no `systemctl` subcommand passthrough. +2. **Two-layer validation.** API server validates for UX (400s); + `box-ctl.py` re-validates as the trust boundary (§4.3). The + helper does not trust the API server. +3. **Name regex everywhere.** `^[a-z0-9-]{1,64}$` on job names, + reuse keys, and request IDs (server-generated hex for the + latter). No path traversal, no unit-name smuggling + (`job-../../x` is impossible). +4. **Prompt templates are data.** They are stored in JSON and + rendered by the dispatcher with a fixed placeholder set. + They never reach a shell, a unit file, or SQL. +5. **Git auditability preserved.** `job-put`/`job-delete` commit + on bl with author ` via box-api`. `git log` on the jobs + dir remains the definition history (JOB-SPEC.md § Security). +6. **Rate limiting.** 10 mutations/min/agent (token bucket, + shared `rate_limiter.py` semantics ported to the API server). + Reads: 60/min/agent. `429` with `Retry-After`. +7. **Dry-run.** `?dry_run=true` on all mutations: runs validation + + helper in check mode, returns the plan, changes nothing. +8. **Idempotency.** `Idempotency-Key` on POST (§5); safe retries + for flaky mobile clients. +9. **Audit everything.** Every mutation → `/srv/box/audit.jsonl` + via `_box_audit` with actor, tier, action, name, request_id, + and result. Reads of timer/job data are audit-logged too + (existing `_box_api` behavior). +10. **Secrets never in responses.** Job definitions contain no + credentials by construction (dispatcher handles none). + DM wire metadata is returned without signature blocks unless + the caller is super/operator (signatures are verification + material, not secret, but redaction is cheap). +11. **Bl unreachable ≠ silent.** SSH failures return `502 + BL_UNREACHABLE` with a `detail`; mutations are never + half-reported as success. + +## 10. Interaction diagram + +``` +Agent / human UI + │ HTTPS + auth (§3) + ▼ +box.muse-dev.online → Caddy → board/server.py::_box_api (VM :8090) + │ validate, authz, audit, rate-limit + │ SSH: box-ctl.py (mutations) + ▼ +bl: bin/box-ctl.py (allowlist + re-validate) + ├── jobs/.json (job-put/get/delete, git-committed) + ├── ~/.config/systemd/user/job-.{service,timer} + │ │ daemon-reload / enable / start / ... + │ ▼ + │ systemd user manager + │ │ OnCalendar fires + │ ▼ + │ job-dispatch.py → dm.py / sidechat send + │ │ [JOB job_id] DM to agent + │ ▼ + │ job-log.jsonl, job-sidechats.json + │ + └── [REQ]/notify path: dm.py → agent chromebox +``` + +Reads (`GET /timers`, `GET /jobs`) follow the same SSH path with +read-only verbs; nothing on bl is modified. + +## 11. Error codes + +| HTTP | `code` | Meaning | +|---|---|---| +| 400 | `INVALID_JOB` | schema validation failed (`detail.field`, `detail.reason`) | +| 400 | `INVALID_SCHEDULE` | cron didn't parse or convert | +| 400 | `UNSUPPORTED_SCHEDULER` | `scheduler` ≠ `"systemd"` | +| 400 | `NAME_MISMATCH` | path name ≠ body name | +| 400 | `BAD_NAME` | name fails `^[a-z0-9-]{1,64}$` | +| 403 | `FORBIDDEN` | auth missing/insufficient; agent not in mutate allowlist | +| 404 | `NOT_FOUND` | timer / job / request unknown | +| 409 | `ALREADY_EXISTS` | job or timer already present | +| 409 | `TIMER_STILL_ACTIVE` | job delete while timer exists (use `?force=true`) | +| 429 | `RATE_LIMITED` | too many ops (`Retry-After` header) | +| 502 | `BL_UNREACHABLE` | SSH to bl failed | +| 502 | `TIMER_CREATE_FAILED` | job written but unit creation failed | +| 502 | `DISPATCH_FAILED` | trigger ran but dispatcher errored | + +All error bodies: `{"error": "", "code": "", "detail": {...?}}`. + +## 12. What changes in existing components + +1. **`bin/box-ctl.py` (new, on bl).** The allowlisted helper (§4). + Lives in the NetVM repo, deployed to bl with everything else. +2. **`bin/job-dispatch.py` (small change).** Accept `--job-id` so + `POST /jobs/{name}/trigger` can mint the ID up front and return + it in the `202` (§5.2). Backward compatible (flag optional). +3. **Result collector (small change).** Recognize + `[CONFIRM {request_id}]` and forward to + `POST /api/box/requests/{id}/confirm` (§8.4). +4. **`/srv/board/server.py` (VM).** New `_box_api` branches for + `/timers`, `/jobs`, `/requests`; `_box_audit` calls; requests + store (`/srv/box/requests.jsonl` + index); idempotency store; + the 60s sweeper timer unit on the VM. +5. **Box web UI.** Timer/job/request pages call these endpoints + (thin client; "UI for Creativity, API for Steering" — every UI + action shows its curl equivalent). + +Nothing changes in `dm.py`, `muse-chat-api.py`, or the systemd +units of existing jobs. + +## 13. Open questions (for the human, not blockers) + +1. Should `POST /timers` send the informational `[REQ]` DM at all, + given §8.2 makes creation confirmations automatic? (Lean: no DM + for auto-confirmed actions; the audit log + request record is + the trail. The UI/API response already tells the caller.) +2. `chain_next` validation requires the target job to exist — should + creation order then force topological creation, or should + dangling `chain_next` be allowed with a warning? (Lean: `400`, + explicit is better than dangling.) +3. The `agent` allowlist (`muse/pip/646/opm`) is hardcoded in §6. + Should it be read live from the machine registry instead? + (Lean: yes at implementation time — one source of truth.) +4. v2 schedulers (`cron`, `at`): still wanted, or is systemd + sufficient for the fleet's needs? + +## 14. Relationship to prior specs + +- `BOX-API-SPEC.md` — background and appendices (DM metadata, + board/chat APIs, confirmation philosophy). Its endpoint sketches + are superseded by §5 here; its auth, rate-limit, and audit + sections are adopted unchanged. +- `JOB-SPEC.md` — the job YAML/JSON format, dispatcher, collector. + This doc defines the API that manages those artifacts; the job + schema in §6 is the JSON rendering of JOB-SPEC.md's YAML. +- `DM-SPEC.md` / `DM_SPEC.md` — `[from:X] [id:Y]` attribution and + signing. `[REQ]`/`[CONFIRM]` DMs reuse that envelope.