# settings.md Vault-backed settings: one catalog, three functions, env always wins. Every `TAI_*` knob that behaves like a setting lives in the `settings` table and is readable and overridable through `get_or_create_settings` / `upsert_settings` / `get_all_settings`. `SettingsTests.test_settings_doc_names_every_key` fails when this file drifts from the code. ## Precedence For every catalog key, the effective value resolves in this order: 1. **env**: a non-empty `${ENV_VAR}` (empty counts as unset, matching every existing reader) 2. **db, own scope**: the profile row when one is stored 3. **db, global scope**: the shared fallback row 4. **catalog default** Two run-scoped inputs outrank even env: CLI `--theme` beats the theme setting, and CLI `--no-opencode` beats the stored opencode flag. A stored model pin counts as explicit, so per-directory model defaults do not override it. Boot applies vault settings in `boot()` (and in `default_runner` for workers) through `boot_settings`: `Config` fields first, then the peripheral keys are exported into `os.environ` so the existing readers, which all run later, pick them up unchanged. Applied keys are printed (`settings from vault: ...`) and also exported for nested processes. A second boot pass is idempotent: exported env wins, so nothing is applied twice. ## Row shape Every function returns the same row dicts: | Field | Meaning | |---|---| | `key`, `env`, `desc` | catalog identity and one-line docs | | `type` | `string`, `bool`, or `float` | | `default` | python value used when nothing is set (may be null) | | `required` | null and empty are rejected; a required key must always resolve | | `nullable` | whether null (unset) is a legal stored value | | `options` | strict list the value must be one of, or null | | `preferred` | suggestions that still allow free input, or null (reserved: no v1 entry uses it) | | `encrypted` | sealed at rest with the vault key when true | | `stored` | decrypted canonical string in the vault, or null | | `value` | effective typed value after precedence | | `source` | where `value` came from: `env`, `db`, or `default` | | `scope`, `created`, `updated` | owning scope and ISO UTC timestamps | Canonical stored forms: bool as `1`/`0`, float as `str(float)`, strings raw. Env bools accept `1/true/yes/on` and `0/false/no/off` (case-insensitive, surrounding whitespace ignored); anything else falls back to the default, exactly like the readers. Env floats use plain decimals (`12`, `2.5`); anything else falls back to the default. Three bool keys extend the env word sets (see catalog): those legacy spellings stay readable from env but are never written by upsert. ## API - `get_or_create_settings(keys=None, scope="global")`: ensure rows exist (all 28 when `keys` is None, a subset or single key otherwise) and return their merged rows. Unknown keys and bad scopes raise `ValueError`, as does a required key that resolves to nothing. - `get_all_settings(scope="global")`: the same merge, read-only: creates no rows. - `upsert_settings(items, scope="global")`: validate a `{key: value}` mapping and store it atomically; anything invalid raises `ValueError` and writes nothing. `None` clears a nullable key back to null. Returns fresh rows for the written keys. Scopes are `global` or a profile name (`PROFILE_RE`). A profile literally named `global` shares the global scope. ## Encryption Keys marked encrypted are sealed with `seal.lock` on write and opened on read; plaintext vaults store them as-is, same as vault secrets. At rest they are indistinguishable from other sealed blobs (`tai1$...`). Passphrase rotation re-seals every sealed settings value and leaves plaintext values alone. Booting a vault that holds sealed settings without the passphrase refuses to start, like the other sealed tables. Encrypted settings are config, not vault secrets: they are not covered by secret redaction, exactly like their env equivalents. ## Catalog 28 keys. Types, defaults, and flags below are asserted by `SettingsTests.test_catalog_self_check`, so this table cannot drift silently. | Key | Env | Type | Default | Req | Null | Options | Enc | What it does | |---|---|---|---|---|---|---|---|---| | `model` | `TAI_MODEL` | string | `muse` | yes | no | - | no | Default model id. A stored pin counts as explicit. | | `backends` | `TAI_BACKENDS` | string | null | no | yes | - | yes | Extra OpenAI-compatible backends: `;`-separated `label\|base\|key[\|model][\|profile]`; a `$VAR` key reads another env var. | | `openrouter_key` | `OPENROUTER_API_KEY` | string | null | no | yes | - | yes | OpenRouter key. Most free models work without one. | | `openrouter_models` | `TAI_OPENROUTER_MODELS` | string | null | no | yes | - | no | Comma-separated model ids. Null means live discovery. | | `openrouter_enabled` | `TAI_OPENROUTER` | bool | true | no | no | - | no | Offer the OpenRouter backend. | | `opencode_enabled` | `TAI_OPENCODE` | bool | true | no | no | - | no | Offer the opencode backend. CLI `--no-opencode` wins. | | `opencode_models` | `TAI_OPENCODE_MODELS` | string | null | no | yes | - | no | Comma-separated model ids. Null means live discovery. | | `devplace_key` | `DEVPLACE_API_KEY` | string | null | no | yes | - | yes | DevPlace key. Null disables the fallback backend. | | `devplace_base` | `TAI_DEVPLACE_BASE` | string | `https://devplace.net/openai/v1` | no | no | - | no | DevPlace API base URL. Trailing slashes stripped. | | `devplace_models` | `TAI_DEVPLACE_MODELS` | string | null | no | yes | - | no | Comma-separated model ids. Null means live discovery. | | `pollinations_enabled` | `TAI_POLLINATIONS` | bool | true | no | no | - | no | Offer the Pollinations backend. | | `pollinations_models` | `TAI_POLLINATIONS_MODELS` | string | null | no | yes | - | no | Comma-separated model ids. Null means live discovery. | | `zen_enabled` | `TAI_ZEN` | bool | false | no | no | - | no | Opt in to the opencode.ai zen backend. | | `zen_models` | `TAI_ZEN_MODELS` | string | null | no | yes | - | no | Comma-separated model ids. Null means live discovery. | | `retry_seconds` | `TAI_RETRY_SECONDS` | float | 0.0 | no | no | - | no | Seconds to retry all backends on total failure. 0 means forever. | | `voice` | `TAI_VOICE` | string | `en-US-EmmaMultilingualNeural` | no | no | - | no | Edge TTS voice name for speak. | | `theme` | `TAI_THEME` | string | `dark` | no | no | dark, light, nord, gruvbox, ansi | no | TUI theme, strict. CLI `--theme` wins. | | `gemini_plan` | `TAI_GEMINI_PLAN` | bool | false | no | no | - | no | Try the Gemini plan approval variant first. Env also accepts `plan`. | | `codex_sandbox` | `TAI_CODEX_SANDBOX` | bool | false | no | no | - | no | Re-enable Codex sandbox flags. Env also accepts `read-only`. | | `box_caps` | `TAI_BOX_CAPS` | bool | true | no | no | - | no | Apply caps to old sandbox boxes. False skips. Env honors only `0` as false. | | `muse_bin` | `MUSE_BIN` | string | null | no | yes | - | no | muse CLI path override. Null means PATH lookup. | | `opencode_bin` | `OPENCODE_BIN` | string | null | no | yes | - | no | opencode CLI path override. Null means PATH lookup. | | `grok_bin` | `GROK_BIN` | string | null | no | yes | - | no | grok CLI path override (then grokcli). Null means PATH lookup. | | `claude_bin` | `CLAUDE_BIN` | string | null | no | yes | - | no | claude CLI path override. Null means PATH lookup. | | `codex_bin` | `CODEX_BIN` | string | null | no | yes | - | no | codex CLI path override. Null means PATH lookup. | | `gemini_bin` | `GEMINI_BIN` | string | null | no | yes | - | no | gemini CLI path override. Null means PATH lookup. | | `opencode_client_name` | `OPENCODE_CLIENT_NAME` | string | null | no | yes | - | no | `x-opencode-client` header override. Null means built-in. | | `opencode_client_ua` | `OPENCODE_CLIENT_USER_AGENT` | string | null | no | yes | - | no | User-Agent override for zen. Null means built-in. | ## Deliberately not cataloged | Var | Why it stays out | |---|---| | `TAI_HOME` | locates the vault; cannot come from inside it | | `TAI_PASSPHRASE` | unlocks the vault; cannot come from inside it | | `TELEGRAM_BOT_TOKEN` | owned by `telegram.env` plus the service unit; move it only with a migration | | `TAI_SESSION` | per-process derived context, resolved around store boot | | `TAI_TUI_SOCK` | per-process socket rendezvous path | | `TAI_TUI_VERIFY` | dev-only pipeline verification switch | | `GRR_URL` | owned by `grr.json` (env wins there too) | | `CAP_SESSION_CONFIG` | wire passthrough for one speak turn, not a setting | | `TAI_PLEXER_*` | runtime plexer state, not settings | | `TERM`, `NO_COLOR`, `SHELL`, `TMUX*`, `EDITOR`, `VISUAL`, `HISTFILE`, `USER`, `TZ`, `VIRTUAL_ENV`, `XDG_*`, `COLORFGBG`, `TERMINFO` | foreign standards tai reads but does not own | Internal tuning constants (`WORKER_STEPS`, `CAP_*`, `BOX_*`, scheduler intervals) are code, not settings: promoting one means a catalog entry, a wiring decision, and a test, not a silent centrally-stored override. `mplex.toml`, CAP fleet files, and per-directory backend pins (`dir_defaults`) keep their own homes for the same reason. ## Maintenance To add a setting: append a `setting_spec` to `SETTINGS_CATALOG`, route it through `CONFIG_SETTING_FIELDS` (read at `Config` build) or `SETTING_EXPORT_KEYS` (read later at call sites), extend the catalog table above, and extend `SettingsTests`. The catalog self-check asserts every default validates, every options list holds its default, env names are unique, and the two routing tuples partition the catalog exactly. The theme row is pinned against `TUI_THEMES` so a new theme breaks the test until the options list follows.