# Setu 0.1.4: extra env variables and scheduled resets

Published 2026-10-21 · Canonical: https://setucp.com/docs/specs/10-20-setu-0-1-4-design

---

Two operator features that let a Setu site run things its manifest did not plan for. The first users are the Vyasa sites on the Setu production server: the public demo (`demo.vyasa.site`) needs demo-mode variables and an hourly reset, and a site that installs packages outside the marketplace needs `VYASA_PACKAGE_TRUSTED_KEYS`.

## 1. Extra env variables

An **extra** variable is a stored env entry whose name the site's manifest does not declare. Nothing marks it in storage: whether a variable is extra is derived from the manifest of the site's current version, wherever it is needed. The format of `sites.env` does not change.

- **API.** `PUT /api/v1/sites/{id}/env` gains two optional fields:

The existing `env` map is unchanged; the three fields together must not be empty. Operator and above, audited as `site.env_update`.
  - `extra`, a map from name to `{ value, secret }`, which adds or replaces extra variables;
  - `remove`, a list of extra names to delete.
- **Validation** (in the API and again in the job):
  - The name matches `^[A-Z_][A-Z0-9_]{0,127}$`.
  - It is not a manifest name. Those go through `env`, so the manifest always wins.
  - It is not reserved. The reserved names are `PATH`, `HOME`, `USER`, `LOGNAME`, `SHELL`, `PWD`, `TMPDIR`, `LANG`, `NOTIFY_SOCKET`, and anything starting with `LD_`, `SYSTEMD_`, `LISTEN_`, `INVOCATION_`, `CREDENTIALS_` or `SETU_`.
  - The value has no control characters.
  - At most 64 extra variables per site.
  - `remove` names must be existing extras.
- **Storage.** A secret extra is sealed (`v1:`), like a secret manifest value, and is masked in `GET env` and readable only through reveal. A non-secret extra is stored `plain:`. `GET env` entries gain `extra: bool`.
- **Job.** `env_update` applies `env`, then `extra`, then `remove`. It stores the result, rewrites the env file and restarts a running site, as today. The payload carries every value sealed, as today.
- **Updates.** After `setu_manifest::merge`, the update job re-appends every existing variable that is extra under the old manifest and not declared by the new one. If the new version starts declaring the name, the manifest rule takes over and the extra value is dropped. A rollback writes the old env, extras included.
- **UI.** The site's Environment tab gets an **Add variable** form (name, value, Secret checkbox) and a Remove action on extra rows. Extra rows carry an "extra" chip.

## 2. Scheduled reset from a backup

An operator can choose one of a site's completed backups as its **reset point** and an interval. Setu then restores that backup on schedule.

- **Schema.** Migration `0003_site_resets.sql` adds a table `site_resets` with one row per site:

A separate table keeps every existing `sites` query unchanged.
  - `site_id`, the primary key, references `sites (id)` with `ON DELETE CASCADE`;
  - `backup_id` references `backups (id)` with `ON DELETE CASCADE`;
  - `every_minutes` is checked to be between 15 and 10080;
  - `last_reset_at` records when the last reset was enqueued.
- **API.**
  - `GET /api/v1/sites/{id}/reset` (viewer and above) returns `{ reset: { backup_id, every_minutes, last_reset_at, next_reset_at } | null }`.
  - `PUT` with `{ backup_id, every_minutes }` sets the schedule (operator and above). The backup must belong to the site and be `completed`. Setting it also sets `last_reset_at = now`, so the first reset comes one interval later.
  - `DELETE` (operator and above) clears the schedule, or returns 404 when there is none.
  - The changes are audited as `site.reset_set` and `site.reset_clear`.
- **Scheduler.** On every tick, a site is reset when all of these hold:

The scheduler then enqueues `restore_site` with `{ backup_id, reset: true }` and sets `last_reset_at = now`. A missed reset happens once, on the next free tick; resets do not pile up.
  - its status is `running`;
  - a schedule is set and the backup still exists and is `completed`;
  - `now >= last_reset_at + every_minutes`;
  - it has no active job.
- **Restore job.** With `reset: true` its `backup` step passes without taking a `pre_restore` backup; the step list stays the same, so a job resumed from its cursor still lines up. Taking a safety copy of a sandbox every hour would fill the disk with 30 days of pre-restore backups. Everything else is unchanged.
- **Retention.** A backup that is any site's `reset_backup_id` is never pruned by the scheduler, whatever its kind. Deleting it by hand is refused with 409 ("backup is the site's reset point; clear the schedule first").
- **UI.** The Backups tab gets **Use as reset point** on completed backups. It opens a dialog with an interval (every 15 min, 30 min, hourly, 6-hourly, daily, or weekly). A summary line at the top of the tab reads "Resets to <backup> every <interval>; next at <time>", with a Stop action.

## Not in scope

Cron jobs inside the app (manifest v2, 0.4), env templating, and per-variable audit history.

## Tests

- **Env:** an extra is added, masked when secret, and revealed; it survives an update to a version that does not declare it; it is replaced by the rule of a version that does.
- **Env validation:** a reserved name, a manifest name and a bad name are each refused in both the API and the job.
- **Remove:** removing an extra rewrites the env file.
- **Scheduler:** it resets when due, never while a job runs, and never twice for one missed window. The reset backup is never pruned, and its deletion is refused.
- **Restore:** with `reset` there is no pre-restore backup.
- **e2e:** not extended in 0.1.4. The env file write is the existing `write_env` path, which e2e already covers.
