# Secrets

Encrypted credentials stored in Supabase Vault, scoped to a house. They exist independently of environments — you can store a `GITHUB_TOKEN` the moment you join a house, before any environment is configured. At dispatch, environments declare which secrets they need via bindings; the system resolves bound names against the house store, decrypts via Vault, and injects the values into the sandbox as env vars in the per-turn `fish -lc`.

```
secrets {
  house_id, name,                                  # UNIQUE(house_id, name) where deleted_at is null
  author_id,                                        # provenance only (who created it) — gates nothing
  vault_secret_id → vault.secrets                   # encrypted value lives here, never in Electric
  deleted_at                                        # soft delete; the unique index filters this
}
```

The house is the privacy boundary: any member can resolve any secret in the house, and a name is unique per house — `GITHUB_TOKEN` means one value, whoever created it. `author_id` records who created the secret (audit) but no longer gates visibility or uniqueness. Schema source: `SecretRowSchema` in `packages/core/schemas/secret.ts`. The `secrets` table stores only metadata — encrypted values never appear in Electric SQL sync.

**Resolution:** each bound name resolves to exactly one secret — uniqueness per house means there is no precedence to arbitrate. Missing required binding → dispatch fails with a clear error naming the secret. Missing optional binding → warning surfaced on the run record's `environment_snapshot` and in the dispatch response. The `resolve_secrets_for_environment` Postgres function runs service-role (bypasses RLS) since Vault access is needed; the dispatch endpoint verifies caller identity first. `SPRITES_TOKEN` is stripped from pi env — pi never sees it. Threads without an environment fall back to house-wide resolution via `resolve_secrets_for_scope` (returns every secret in the house). Local `.env` file injection continues to work for unauthenticated/offline usage.

Environments don't hold secret values — they hold a list of names (`secret_bindings: [{name, required?}]`, `required` defaults to `true`). A binding stores only the name; the value resolves against the house store at dispatch, so create order doesn't matter — a binding can reference a name that doesn't exist yet. One secret, many environments — rotate once, every environment that binds it gets the new value on the next dispatch.

```sh
arbe secret list                                    # names, rotation age
arbe secret view <name>                             # metadata only; --reveal exists but isn't wired
arbe secret set <name>                              # upsert via stdin (rotation = same name)
arbe secret delete <name>                           # soft delete

echo "sk-or-..." | arbe secret set OPENROUTER_API_KEY
echo "ghp-..." | arbe secret set GITHUB_TOKEN
```

Values are always read from stdin, never argv. `set` is an upsert — if a secret with that name already exists, it rotates the value instead of creating a duplicate. **Names are immutable** (there's no PATCH — a secret has no mutable metadata) — renaming would silently break every binding; delete and recreate instead. Storing is independent of binding — once the secret exists, attach it via `arbe env bind-secret <env> <NAME>` (add `--optional` to warn instead of fail at dispatch) or pass `--secret NAME` / `--optional-secret NAME` to `arbe env create`. Sandbox pi defaults to OpenRouter, so the usual binding is `OPENROUTER_API_KEY`. Remove with `arbe env unbind-secret`.

API: `GET/POST /api/secrets`, `GET/DELETE /api/secrets/:id`, `PUT /api/secrets/:id/value` (rotate), plus the internal `POST /api/secrets/resolve` (bearer-auth — the only endpoint that returns decrypted values). `POST` and `PUT` return the metadata row; `DELETE` returns `{ id }` so callers can update local state without a refetch.

**Visibility ≠ dispatch.** Being able to dispatch a run with an environment does not imply being able to read secret values. `arbe env view` lists bound secret names + metadata via a join, never plaintext. House admins can delete secrets they don't own but cannot reveal their values.

**Storage.** Encryption uses Supabase Vault (pgsodium AEAD with a Supabase-managed key). `vault.decrypted_secrets` provides on-the-fly decryption inside Postgres — no application-layer crypto needed. Vault labels use the format `name/house_id/author_id` to avoid collisions. Soft-delete sets `deleted_at` on the metadata row and hard-deletes the Vault entry (encrypted values don't need tombstones). A cascade trigger cleans up secrets when a house is soft-deleted.

Code: `packages/core/schemas/secret.ts`, `packages/core/schemas/environment.ts` (`SecretBindingSchema`), `apps/www/src/routes/api/secrets/`.<br>
See [system/environments](./environments.md), [system/dispatch](./dispatch.md), [system/auth](./auth.md).
