# M4b task 07: `[approvals]` in `gatewayd.toml` **Branch:** `m4b` (run `git switch m4b`; `git status --short` must be empty, otherwise stop) **Commit subject:** `gatewayd: config, [approvals] users and socket` ## Goal Who may answer approvals, and where `brokerd` brings them (M4b spec, section 3): ```toml [approvals] users = [] # Mattermost user ids who may answer; empty: everyone in [allow] users socket = "" # empty: /run/gateway/gateway.sock ``` An approver must also be in `[allow] users`: one who is not heard could never answer, so that is a configuration error. The **first approver** (`approvals.users[0]`, or `allow.users[0]` when the list is empty) gets, in direct messages, the approvals of sessions that are not in Mattermost. ## Files - Copy: `crates/gatewayd/tests/config_approvals.rs` - Modify: `crates/gatewayd/src/config.rs`, `docs/runbook.md`, `docs/implementer-log.md` ## The changes, exactly In `crates/gatewayd/src/config.rs`: 1. In `struct Config`, after `pub limits: Limits,`: ```rust #[serde(default)] pub approvals: ApprovalsConfig, ``` and after `struct Config`: ```rust /// `[approvals]`: who may answer approvals, and where `brokerd` brings them. #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)] #[serde(deny_unknown_fields, default)] pub struct ApprovalsConfig { /// Mattermost user ids; empty means everyone in `[allow] users`. pub users: Vec, /// Empty: `/run/gateway/gateway.sock`. pub socket: PathBuf, } ``` 2. In `problem`, right before the `[limits]` checks (`if self.limits.queue == 0`): ```rust for id in &self.approvals.users { if !self.allow.users.contains(id) { return Some(format!( "[approvals] {id:?} is not in [allow] users: an approver who is not heard could never answer" )); } } ``` 3. In `impl Config`, before `state_path`, three methods. Write them from these descriptions: - `pub fn approvers(&self) -> &[String]`: `&self.approvals.users`, or `&self.allow.users` when the first is empty. - `pub fn first_approver(&self) -> Option<&str>`: the first of `approvers()` (`.first().map(String::as_str)`). - `pub fn gateway_socket(&self) -> PathBuf`: `self.approvals.socket`, or `self.paths.home.join("run/gateway/gateway.sock")` when it is empty (`as_os_str().is_empty()`). In `docs/runbook.md`, entry `gatewayd-start-failed`, replace the **Why** and **Confirm** paragraphs with: ```markdown **Why.** `gatewayd` could not read or parse `gatewayd.toml`, or a value in it is not allowed: a `url` that is not `http://` or `https://` with a host and an optional port, an id that is not 26 characters of `a-z0-9`, an empty `[allow] users`, an id in `[approvals] users` that is not also in `[allow] users`, or a secret with not exactly one of `credential`, `env` and `file`. Or it could not create `/gateway/`, or could not listen on `gateway.sock` (its directory is made 0700 and the socket 0600, as `brokerd`'s are; a path of 108 bytes or more cannot be a socket). It will not guess at a configuration that decides who it answers and who approves. **Confirm.** The line says which: `: …` for the file (unknown keys are errors; check it against `docs/specs/2026-09-23-m4a-gateway.md`, section 3, and `docs/specs/2026-09-24-m4b-approvals.md`, section 3), `cannot prepare : …` for a directory (`ls -ld "$(dirname )"`), or `cannot listen on : …` for `gateway.sock` (`Address already in use` means another `gatewayd` runs: `pgrep -x gatewayd`). ``` ## Steps - [ ] **1. Copy.** `git switch m4b`, then `cp docs/plans/M4b/files/crates/gatewayd/tests/config_approvals.rs crates/gatewayd/tests/` - [ ] **2. See it fail.** `cargo test -p gatewayd --test config_approvals`. Expected: it does not compile. - [ ] **3. Make the changes.** Run `cargo fmt --all`. - [ ] **4. See it pass.** `cargo test -p gatewayd --test config_approvals --test config`. Expected: 4 and 7 passed. - [ ] **5. Run the gate.** `make gate`. Expected last line: `gate: ok`. - [ ] **6. Log and commit.** `git add crates/gatewayd docs/runbook.md docs/implementer-log.md Cargo.lock && git commit` ## Done when - Both suites pass; `make gate` prints `gate: ok`.