--- 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`, `--owner`, `--secret`, `--secret-file`, `--blob-quota`, `--max-blob`, `--name-hold`, `--names`, `--names-if-down`, `--estop-file`, `--estop-socket`, `--close-disclosure`, `--avatar`, `--demo`, and more — nineteen 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 owner'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 owner's repository, read at runtime, per [policy-store](policy-store.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.rs`'s constants rather than guessed: - **`[server]`** — bind address, port. - **`[zone]`** — the zone agents are minted under, the owner DID, the pds endpoint agents are told to use. - **`[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`). - **`[attestation]`** — backend selection and the node allowlist. - **`[blobs]`** — `max_blob_bytes`, `account_quota_bytes`, 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`, how long a sealed segment of the policy evaluation log survives before `Enforcement::open` and its sweep trim it (`didbot_pds::evaluation_log::FileSink::set_retention`). Wired, with no flag mirroring it yet. - **`[names]`** — the naming spec or template, the fallback namer, the hold period. - **`[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. - **`[estop]`** — the file latch path and the admin socket path. - **`[policy]`** — which DID's records this server reads for [policy-store](policy-store.md), once that epic exists to read anything. 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: - [ ] `--port` / bind address (a sibling change is already in `didbot-pds.rs`) - [ ] `--zone`, `--owner` - [ ] `--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) - [ ] `--names`, `--names-if-down`, `--name-hold` - [ ] `--estop-file`, `--estop-socket` - [ ] `--avatar`, `--demo` (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 - [ ] DNS provider selection, once [dns-providers](dns-providers.md) has a second backend - [ ] TLS section, once `didbot-tls`'s configuration surface is stable - [ ] `[policy]`, once [policy-store](policy-store.md) exists to read ## 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-store.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.