# M6b task 03: `[consult]` and `[secrets]`, start fails closed **Branch:** `m6b`. Check it: `git branch --show-current` prints `m6b` and `git status --short` is empty; otherwise stop. Never switch branch. **Commit subject:** `brokerd: consult config, secrets, loopback URL` **Log row:** the Task cell is `M6b/03-brokerd-consult-config`. **First action.** Run this before any Read of crate source. Do not `ls` `crates/brokerd/tests/`. Do not Read `args.rs`, `broker.rs`, `ledger/`, `docs/implementer-log.md` (except the first 12 lines at the log step). Do not edit `crates/brokerd/tests/config.rs`. The signatures below are complete. ```sh cp docs/plans/M6b/files/crates/brokerd/tests/config_consult.rs crates/brokerd/tests/ cp docs/plans/M6b/files/crates/brokerd/tests/consult_secret.rs crates/brokerd/tests/ ``` Then append the runbook fragment (step 1 of Steps names the exact command). ## Goal `brokerd.toml` may have `[consult]` and `[secrets.]`. A present `[consult]` is validated at load: loopback `http://` only, non-empty model and secret, `timeout_ms` ≥ 1, temperature from 0 to 0.6 inclusive. Temperature is stored as `temperature_milli: u16` so `Config` stays `Eq`. No `[consult]` is `None` and an empty `[secrets]` map. Loading the named secret at start is `load_secret`; a missing or unreadable secret is `consult-secret-unreadable` and `brokerd` must not start. This task does not POST, parse tool arguments, or change `ToolName`. ## Every place that changes | Place | What happens there | |---|---| | `crates/brokerd/Cargo.toml` | `bxcore` enables feature `secrets` | | `crates/brokerd/src/consult.rs` | new: `Consult`, `validate`, `parse_loopback_http`, `load_secret` | | `crates/brokerd/src/lib.rs` | `pub mod consult;` | | `crates/brokerd/src/config.rs` | `consult`, `secrets`; validate at load | | `crates/brokerd/tests/support/rig.rs` | the `Config { }` literal names the new fields | | `docs/runbook.md` | append both consult entries | | `crates/brokerd/tests/config_consult.rs` | new, given | | `crates/brokerd/tests/consult_secret.rs` | new, given | | everything else | **no change**: not `ToolName`, not `main.rs`, not `ledger` | ## Files - Copy, from `docs/plans/M6b/files/` (the same path under it): the new tests `crates/brokerd/tests/config_consult.rs` and `crates/brokerd/tests/consult_secret.rs`. - Append, from `docs/plans/M6b/files/runbook-consult.md`, to `docs/runbook.md`. Both headings go in now so `scripts/check-runbook.sh` accepts `SECRET_POINTER`; task 05 adds the `consult-not-configured` pointer in code. - Create: `crates/brokerd/src/consult.rs` (the module below). - Modify: `crates/brokerd/Cargo.toml`, `crates/brokerd/src/lib.rs`, `crates/brokerd/src/config.rs`, `crates/brokerd/tests/support/rig.rs`, `docs/runbook.md`, `docs/implementer-log.md`. - Do not touch: any other file. Do not edit `crates/brokerd/tests/config.rs`. After the Cargo.toml change, run `cargo build` once so `Cargo.lock` updates. ## Facts you need - **No vendor names.** The URL, model and secret name are configuration. The module must not mention a cloud provider. - **Loopback HTTP only.** `parse_loopback_http` accepts `http://127.0.0.1` or `http://localhost`, optional port 1–65535 and path. `https://`, a non-loopback host, a userinfo (`@`), or port `0` is an error. Default port is 80. A URL with no path uses `"/"`. - **Temperature.** TOML `temperature` is an `f64` in 0..=0.6, or the integer `0`. Any other integer is invalid (`must be from 0 to 0.6`). Store `(v * 1000.0).round()` as `u16` via `format!("{milli:.0}").parse::()`. Default 600 (0.6). `Config` stays `Eq`; do not store `f64` on `Consult`. - **`#[serde(default)]` on the new `Config` fields.** `an_empty_file_gets_every_default` in `config.rs` compares a loaded empty file to `Config::default()`. Missing tables are `consult: None` and `secrets: {}`. - **The only `Config { }` literal** is `crates/brokerd/tests/support/rig.rs`. Add `consult: None, secrets: Default::default()`. Other tests load TOML. - **Secrets.** Same `SecretSpec` as `gatewayd`: exactly one of `credential` / `env` / `file`. `load` is `bxcore::secrets::load`. Role string `"brokerd"`. Load the consult secret only when `[consult]` is present; absent table is `Ok(None)`. - **Do not add `ToolArgs`, `QUESTION_CAP`, `NOT_CONFIGURED`, a transport, or HTTP.** Those are later tasks. An unused constant fails clippy. - The items the edits use. This table is complete. | Item | Signature | |---|---| | `SecretSpec` | `{ credential: Option, env: Option, file: Option }` in `bxcore::secrets` | | `SecretSpec::source` | `source(&self) -> Result` | | `bxcore::secrets::load` | `load(role: &str, name: &str, source: &SecretSource, env: &dyn Fn(&str) -> Option) -> Result` | | `Loaded` | `{ secret: Secret, warning: Option }` | | `SecretError` | `Display` names the secret and `docs/runbook.md#secret-unavailable` | | `Config::load` | `load(path: &Path) -> Result` | | `ConfigError::Invalid` | `Invalid(PathBuf, String)` | | `ConfigError::Parse` | `Parse(PathBuf, toml::de::Error)` | ## The edits, exactly ### `crates/brokerd/Cargo.toml` **C1.** The line ```toml bxcore.workspace = true ``` becomes ```toml bxcore = { workspace = true, features = ["secrets"] } ``` Then `cargo build` once (timeout 600000 ms) so `Cargo.lock` records the feature. ### `crates/brokerd/src/lib.rs` **L1.** After `pub mod config;` add `pub mod consult;`. rustfmt decides the final order. ### `crates/brokerd/src/consult.rs` Write this module. Do not add `ConsultArgs`, `wire_body`, `ConsultTransport`, or `ConsultRuntime`. ```rust //! `consult`: an allowlisted chat-completions POST decided by the grant's `max_taint`. //! Nothing here names a vendor (M6 spec, section 4). use std::ffi::OsString; use std::time::Duration; use bxcore::secrets::{Loaded, SecretError, SecretSpec}; use serde::{Deserialize, Deserializer}; use crate::config::Config; pub const CONSULT: &str = "consult"; /// Default wait for the remote completion, milliseconds. pub const DEFAULT_TIMEOUT_MS: u64 = 180_000; /// Default sampling temperature as thousandths (0.6). pub const DEFAULT_TEMPERATURE_MILLI: u16 = 600; /// Start failure when the named secret cannot be read. pub const SECRET_POINTER: &str = "see docs/runbook.md#consult-secret-unreadable"; /// `[consult]` in `brokerd.toml`. Unknown keys are errors. #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(deny_unknown_fields)] pub struct Consult { pub url: String, pub model: String, #[serde( rename = "temperature", default = "default_temperature_milli", deserialize_with = "de_temperature_milli" )] pub temperature_milli: u16, #[serde(default = "default_timeout_ms")] pub timeout_ms: u64, pub secret: String, } fn default_temperature_milli() -> u16 { DEFAULT_TEMPERATURE_MILLI } fn default_timeout_ms() -> u64 { DEFAULT_TIMEOUT_MS } fn de_temperature_milli<'de, D: Deserializer<'de>>(deserializer: D) -> Result { #[derive(Deserialize)] #[serde(untagged)] enum Num { F(f64), I(i64), } let v = match Num::deserialize(deserializer).map_err(serde::de::Error::custom)? { Num::F(f) => f, Num::I(0) => 0.0, Num::I(_) => { return Err(serde::de::Error::custom( "[consult] temperature must be from 0 to 0.6", )); } }; if !v.is_finite() || v < 0.0 || v > 0.6 { return Err(serde::de::Error::custom( "[consult] temperature must be from 0 to 0.6", )); } let milli = (v * 1000.0).round(); let text = format!("{milli:.0}"); text.parse::().map_err(serde::de::Error::custom) } impl Consult { /// The temperature written in the JSON body. pub fn temperature(&self) -> f64 { f64::from(self.temperature_milli) / 1000.0 } pub fn timeout(&self) -> Duration { Duration::from_millis(self.timeout_ms) } } /// Why a `[consult]` table cannot be used. pub fn validate(consult: &Consult) -> Result<(), String> { parse_loopback_http(&consult.url).map(|_| ())?; if consult.model.is_empty() { return Err("[consult] model must not be empty".to_string()); } if consult.timeout_ms == 0 { return Err("[consult] timeout_ms must be at least 1".to_string()); } if consult.secret.is_empty() { return Err("[consult] secret must not be empty".to_string()); } if consult.temperature_milli > DEFAULT_TEMPERATURE_MILLI { return Err("[consult] temperature must be from 0 to 0.6".to_string()); } Ok(()) } /// Host, port and path of a loopback `http://` URL. M6 stays on loopback. pub fn parse_loopback_http(url: &str) -> Result<(String, u16, String), String> { let rest = match url.strip_prefix("http://") { Some(rest) => rest, None => { return Err("[consult] url must be http://127.0.0.1 or http://localhost".to_string()); } }; if rest.contains('@') { return Err("[consult] url must not include a userinfo".to_string()); } let (hostport, path) = match rest.find('/') { Some(i) => match rest.split_at_checked(i) { Some(pair) => pair, None => { return Err("[consult] url is not a valid http URL".to_string()); } }, None => (rest, "/"), }; let path = if path.is_empty() { "/" } else { path }; let (host, port) = match hostport.split_once(':') { Some((host, port)) => { let port: u16 = port .parse() .map_err(|_| "[consult] url port is not a number".to_string())?; if port == 0 { return Err("[consult] url port must be from 1 to 65535".to_string()); } (host, port) } None => (hostport, 80), }; if host != "127.0.0.1" && host != "localhost" { return Err("[consult] url must be http://127.0.0.1 or http://localhost".to_string()); } Ok((host.to_string(), port, path.to_string())) } /// Why `brokerd` will not start with `[consult]` set. #[derive(Debug)] pub enum SecretStartError { Missing(String), Spec(String, String), Load(SecretError), } impl std::fmt::Display for SecretStartError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { SecretStartError::Missing(name) => { write!(f, "secret {name} is not in [secrets]; {SECRET_POINTER}") } SecretStartError::Spec(name, why) => { write!(f, "[secrets.{name}]: {why}; {SECRET_POINTER}") } SecretStartError::Load(err) => write!(f, "{err}\n{SECRET_POINTER}"), } } } impl std::error::Error for SecretStartError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { SecretStartError::Load(err) => Some(err), SecretStartError::Missing(_) | SecretStartError::Spec(_, _) => None, } } } /// Load the bearer for `[consult]`, or `Ok(None)` when the table is absent. pub fn load_secret( cfg: &Config, env: &dyn Fn(&str) -> Option, ) -> Result, SecretStartError> { let Some(consult) = &cfg.consult else { return Ok(None); }; let spec: &SecretSpec = match cfg.secrets.get(&consult.secret) { Some(spec) => spec, None => return Err(SecretStartError::Missing(consult.secret.clone())), }; let source = spec .source() .map_err(|why| SecretStartError::Spec(consult.secret.clone(), why))?; let loaded = bxcore::secrets::load("brokerd", &consult.secret, &source, env) .map_err(SecretStartError::Load)?; Ok(Some(loaded)) } ``` ### `crates/brokerd/src/config.rs` **F1.** The imports ```rust use serde::Deserialize; use std::path::{Path, PathBuf}; ``` become ```rust use serde::Deserialize; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use bxcore::secrets::SecretSpec; ``` **F2.** After `pub memory: Memory,` (the last field of `Config`) add: ```rust #[serde(default)] pub consult: Option, #[serde(default)] pub secrets: BTreeMap, ``` **F3.** In `Config::load`, immediately before `Ok(config)`, add: ```rust if let Some(consult) = &config.consult && let Err(why) = crate::consult::validate(consult) { return Err(ConfigError::Invalid(path.to_path_buf(), why)); } ``` Do not change `time_limit` (no `ToolName::Consult` yet). ### `crates/brokerd/tests/support/rig.rs` **R1.** The `Config {` fields ending ```rust gateway: None, memory: Default::default(), }; ``` become ```rust gateway: None, memory: Default::default(), consult: None, secrets: Default::default(), }; ``` ### `docs/runbook.md` **B1.** Append the file `docs/plans/M6b/files/runbook-consult.md` as-is (both `##` headings) after the last line of `docs/runbook.md`. ## Steps - [ ] **1. Copy the given tests and append the runbook.** ```sh cp docs/plans/M6b/files/crates/brokerd/tests/config_consult.rs crates/brokerd/tests/ cp docs/plans/M6b/files/crates/brokerd/tests/consult_secret.rs crates/brokerd/tests/ cat docs/plans/M6b/files/runbook-consult.md >> docs/runbook.md ``` - [ ] **2. See them fail.** `cargo test -p brokerd --test config_consult --test consult_secret`, timeout 600000 ms: both targets do not compile (`E0432` unresolved `brokerd::consult`). - [ ] **3. The edits.** C1 (then `cargo build`), L1, write `consult.rs`, F1–F3, R1, B1. `cargo check -p brokerd` prints no error and no warning. `wc -l crates/brokerd/src/consult.rs` and `wc -l crates/brokerd/src/config.rs` each ≤ 500. - [ ] **4. The tests.** `cargo test -p brokerd --test config_consult --test consult_secret --test config`: every test passes, 6 in `config_consult`, 3 in `consult_secret`. `an_empty_file_gets_every_default` still passes. - [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`, timeout 600000 ms. Last line `gate: ok`. Test count **1602** (1593 before, plus 9). After `gate: ok`, do not add up `test result:` lines. - [ ] **6. Log and commit.** Task cell `M6b/03-brokerd-consult-config`. Read only the first 12 lines of `docs/implementer-log.md`; insert the row under the header. Stage with `git add crates/brokerd Cargo.lock docs/runbook.md docs/implementer-log.md`, then commit as `AGENTS.md` says. ## Done when - `make gate` prints `gate: ok` with 1602 tests. - `git diff HEAD~1 --stat` lists only `crates/brokerd/Cargo.toml`, `crates/brokerd/src/consult.rs`, `crates/brokerd/src/lib.rs`, `crates/brokerd/src/config.rs`, `crates/brokerd/tests/support/rig.rs`, `crates/brokerd/tests/config_consult.rs`, `crates/brokerd/tests/consult_secret.rs`, `docs/runbook.md`, the log, and `Cargo.lock` if it changed. ## Stop and report if - The branch check fails; a "before" block is missing or duplicated; step 2 compiles; you edit `tests/config.rs` or add `ToolName::Consult`; `cargo check` warns; existing `config` tests fail; a test outside the named files fails in the gate (`git stash -u`, rerun, `git stash pop`, report both). ## What the given tests pin No `[consult]` is `None` with empty secrets. A table loads url/model/secret and defaults temperature 0.6 / timeout 180_000. `temperature = 0.7` is parse-invalid. A non-loopback URL is `ConfigError::Invalid`. An unknown `[consult]` key is parse-invalid. `[secrets.consult]` loads. A missing secrets entry and an unset env secret both name `consult-secret-unreadable`; a present env secret loads.