--- id: config title: How the server runs is one file; what an agent may do is never in it status: open crates: [didbot-config, didbot-serve] dependsOn: [] exitCriterion: > didbot-pds starts from a sectioned TOML file covering every setting listed below, refuses to start on an unknown key or a malformed value naming the problem, complains about a loosely permissioned secret file, and states in one place how the file, command-line arguments and the environment resolve against each other. --- # config `didbot-pds` takes `--zone`, `--data`, `--port`, `--operator`, `--secret`, `--secret-file`, `--blob-quota`, `--max-blob`, `--name-hold`, `--names`, `--names-if-down`, `--close-disclosure`, `--avatar`, and more — twenty-seven flags today, and every epic that touches this binary adds one. `--secret` already needed a `--secret-file` twin because process arguments are world-readable in `/proc//cmdline`, which is [deployment](../docs/deployment.md)'s own finding. A flag list that long is not a bootstrap interface any more; it is configuration with no schema, no file, and no way to see the whole shape of a deployment at once. ## The tier this file is [deployment](../docs/deployment.md)'s configuration-tiers table names three tiers by what they hold, not by what stores them, and the table's row for the second tier undersells it: it says "process arguments," and this epic adds a sibling that is reachable the same way. Both are: | Tier | Holds | Reachable on the host | |---|---|---| | The operator's repository | What an agent may hold: ceilings, admitted apps, collection permissions | No | | Process arguments and this file | Bootstrap and how the server runs: bind address, zone, state directory, which DID's policy to read | Yes | | Local state | Caches, refusal records, the emergency stop | Yes | Anyone who holds the host can read this file. That makes it the single most tempting place to let a scope ceiling, an admitted app or a collection permission drift into, because it is right there, it is TOML, and it is already where the operator is looking. **It must not become one.** Those stay in the operator's repository, read at runtime, per [policy](policy.md). This file may say *which* DID's policy to read; it may never say what that policy is allowed to grant. A reviewer checking a change to this crate's schema should be able to reject anything that adds a scope, a permission, or an allowlist of apps on that question alone. ## Sections Grouped by what an operator is thinking about, read from the real flag set and from `didbot-serve/src/routes/mod.rs`'s constants rather than guessed: - **`[server]`** — bind address, port. Wired. - **`[zone]`** — the zones agents are minted under, the operator DID, the pds endpoint agents are told to use. Wired; `zones` holds the `--zone` list, whose first entry is the primary. - **`[dns]`** — provider selection and its zone, once `didbot-dns` has more than one backend ([dns-providers](dns-providers.md)). - **`[tls]`** — ACME environment, contact address, certificate source (`didbot-tls`). `acme_environment` and `contact` are wired. - **`[attestation]`** — backend selection and the node allowlist. - **`[blobs]`** — `max_blob_bytes`, `account_quota_bytes`, `total_quota_bytes` (every account's blobs together, with no flag mirroring it), and a name the blob garbage-collection work is free to add a retention setting under (`collection_grace`, matching `didbot_pds::blobs::BlobLimits`'s field, is the name already in use on that branch — this section should accept it under the same name rather than inventing a second one). Wired, except `collection_grace_secs`, which is wired now, against `--blob-collection-grace-hours` to wire it to yet. - **`[capacity]`** — `max_accounts`, against `--max-accounts` (`plan/capacity.md`). Wired. `evaluation_log_retention_days` and `write_log_retention_days`, how long a sealed segment of the policy evaluation log and of the write log survive before `Enforcement::open` and its sweep trim them (`didbot_pds::row_log::RowLog::set_retention`). Two keys because the logs fill at unrelated rates: denials are attacker-driven volume, attributed writes grow with real use. Wired, with no flag mirroring either yet. - **`[names]`** — the naming spec or template, the fallback namer, the number encoding, the hold period. Wired, against `--names`, `--names-if-down`, `--number-encoding` and `--name-hold`. A spec that will not build names the tier it was written in. - **`[limits]`** — rate limits and their windows, once `didbot-serve/src/rate_limit.rs` and `oauth/rate_limit.rs` converge on one shape. - **`[disclosure]`** — which `bot.did.*` routes are closed; mirrors `auth::Disclosure`. Wired. - **`[operator]`** — `session_ttl_secs`, how long a signed-in operator stays signed in, against `--operator-session-ttl`. Wired, and the section's own doc carries the range worth staying inside. - **`[admission]`** — how many parent links a provenance chain may cross, against `--admission-depth`. Wired. - **`[operator]`** — `session_ttl_secs`, against `--operator-session-ttl`, and `grace_window_hours`, how long an operator this server cannot reach keeps it writing, against `didbot_pds::DEFAULT_GRACE_WINDOW`. Wired; the grace window has no flag mirroring it. - **`[credential]`** — `agent_token_ttl_days`, how long a freshly issued agent token is good for, against `didbot_pds::credential::DEFAULT_AGENT_TOKEN_TTL`. Wired; no flag mirrors it. - **`[intervals]`** — the background loops' periods: blob collection, the confinement poll and the health tick. A key each because they load different things — the confinement poll spends a DNS provider's rate limit, blob collection walks this server's disk, the health tick reads memory. Wired, with no flags mirroring them. - **`[policy]`** — which DID's records this server reads for [policy](policy.md)'s operator source; the poll takes the operator from the server's own `--operator` today. Every section above is schema from day one — an operator should be able to read one file and see the whole shape of a deployment — but only some of it is wired to anything that runs; see "What this pass wires" below. ## Behaviours every section has to satisfy - [ ] **A secret is a path, never a value.** The file names where a secret lives — the same shape `--secret-file` already established for the command line — and never carries one itself. A key that looks like it wants a literal secret is a schema mistake, not a feature. True of every wired secret path today (`--secret-file`); still open because `[attestation]`'s own `secret_file` field is schema only, not yet read by anything. - [ ] **One stated precedence.** Command-line arguments win over the file, which wins over the environment, decided in one place (`didbot_config::precedence`) and documented there rather than reconstructed per flag. Arguments are the leakiest tier — world-readable in `/proc//cmdline` — so a secret path may be given there, but a secret value itself should not be; `--secret` stays for development against the placeholder default, and a real deployment is steered toward the file or `--secret-file`. The environment tier is decided in the function's signature but has no caller yet — nothing in this pass reads an environment variable. - [ ] **Every setting says whether it survives a restart.** Certificates already hot-reload (`didbot-tls`); most of this file will not, on first pass, because nothing here builds the watch-and-reload plumbing yet. A setting that cannot be reloaded and is edited in the file while the server is running must fail loudly on the next read that notices, not silently keep running on the old value — even before a watcher exists, the schema should carry which settings are which, so the question has one answer instead of one per reader. ## What this pass wires Four other lines of work are touching `didbot-pds.rs`, `rate_limit.rs`, `didbot-tls`, and `didbot-dns` at the same time this epic starts, so it builds the schema for every section above and wires only a slice that does not collide: `--config` reads a file through `didbot_config::Config::load`, `[blobs]` and `[disclosure]` are applied with `--max-blob`/`--blob-quota`/ `--close-disclosure` taking precedence, and `--secret-file` is checked for loose permissions. That proves the refusal behaviours end to end — unknown key, malformed value, loose permissions — without touching the bind address, the rate limiter, or either provider crate. Left as flags, on purpose, and listed here rather than converted in this pass: - [ ] `--secret` itself (only the file-permission check for `--secret-file` is wired; the value's own precedence is decided above but not yet enforced in code) - [ ] `--avatar` (development-only; may never belong in a deployment's file at all — worth deciding when this list is next revisited rather than now) - [ ] rate limit windows, once `rate_limit.rs` settles — including `--dashboard/login`'s own per-address budget (`routes::OPERATOR_LOGIN_LIMIT`), which is held as a constant for the same reason its siblings are - [ ] DNS provider selection, once [dns-providers](dns-providers.md) has a second backend - [ ] `[tls] cert_source`, a path to a fixed key pair, once `didbot-tls` reads one. It is the field `check_secret_permissions` was written for: the check runs on the path this key names. - [ ] `[policy]`, wired to [policy](policy.md)'s poll, which takes the operator from the server's own `--operator` today ## Done - [x] **An unknown key refuses startup**, named in the error. `#[serde(deny_unknown_fields)]` is on `Config` and on every section struct in `crates/didbot-config`, so a key nobody recognises is a startup failure rather than a server that looks configured and is not — the same fail-closed call `plan/policy.md` makes for a policy version that will not validate. Proven at both levels: `an_unknown_top_level_key_refuses`, `an_unknown_key_inside_a_known_section_refuses` and `a_malformed_value_refuses_naming_the_problem` in the crate, and `an_unknown_key_refuses_and_names_it` end to end through the binary. - [x] **A secret file's permissions are complained about.** `didbot_config::secret::check_secret_permissions` masks `0o077` and reports path and mode, over four unit tests in `secret.rs`. It has no caller today: the `--secret-file` and `--operator-secret-file` flags it was wired to went away with the shared secret itself, and no field this crate parses names a secret file yet. `[tls] cert_source` is the first that will, when a path to a fixed key pair becomes something a run actually reads; this is the check that path takes. Nothing else is left unguarded by that. Every file this workspace writes a secret into is created `0600` under a `0700` directory by the crate that owns it — `didbot-pds`'s write-ahead log, `didbot-tls`'s certificate store, `didbot-serve`'s e-stop socket — each with a test over the mode of the file it actually wrote, rather than by a check at a read path. - [x] **The `didbot-config` crate: a schema for every section, and the refusal behaviours proven against it.** Every section carries `#[serde(deny_unknown_fields)]`, so an unknown key or a malformed value refuses `Config::load` with `toml`'s own message naming the field — tested directly in the crate and again through `didbot-pds`'s own `--config` handling. `check_secret_permissions` reports (does not refuse) a secret file readable beyond its owner, and `precedence` is the one function that decides command line vs. file vs. environment, used to resolve `[blobs]` against `--max-blob` and `--blob-quota`. - [x] **`didbot-pds --config` reads `[blobs]` and `[disclosure]`.** A flag still wins over the file for the field it sets; the file fills in anything the flag left alone; `[disclosure]`'s narrowing composes with `--close-disclosure`'s in either order because both only ever close a route. - [x] **Every section the binary parses reaches something that runs.** `[server]`, `[zone]`, `[tls]`, `[blobs]`, `[capacity]`, `[names]`, `[park]`, `[tree]`, `[oidc]`, `[sessions]`, `[credential]`, `[intervals]`, `[disclosure]`, `[operator]`, `[oauth]` and `[relay]` are applied, each through `apply_config` or a `resolve_*` of its own. `[dns]` and `[policy]` name settings whose readers belong to [dns-providers](dns-providers.md) and [policy](policy.md). - [x] **A parameter an operator should be choosing has a key, and one they should not says why in its own doc.** `[capacity] log_budget_bytes`, `[tree] max_depth`, `[operator] grace_window_hours`, `[credential] agent_token_ttl_days`, `[names] number_encoding` and the four `[intervals]` keys are the first half. The second is written beside the constants: `row_log` and `wal`'s segment sizes are the granularity a configured retention or budget is reclaimed in, `wal::DEFAULT_SYNC_INTERVAL` holds a fixed ratio to `heap::SYNC_INTERVAL`, `heap::DEFAULT_BUFFER_BYTES` is sized against one repository rather than a deployment, `evaluation_log::DEFAULT_WINDOW` is paired with the sweep that flushes it, `DEFAULT_MAX_OPEN_WINDOWS` is a memory backstop whose position is not observable, `writequeue::DEFAULT_CAPACITY` follows from how fast policy drains a queue, `blobs::DEFAULT_LIST_LIMIT` is the lexicon's number, and `rate_limit::DEFAULT_LIMIT`/`WINDOW` stay constants until they and `oauth/rate_limit.rs`'s own budgets converge on the one `[limits]` shape above, rather than each getting a key a later pass would have to reconcile.