Files
boxmaker/docs/plans/M6b/03-brokerd-consult-config.md
T
kyleandGrok 4.6 50e9cc2037 docs: M6b implementation plan
Six closed tasks for consult: registry, CONSULT_WAIT, [consult]/[secrets],
ToolName and grants, Decision body, loopback HTTP. Reference is the git bundle
~/boxmaker-refs/m6b-ref.bundle at 5fb05f0. Gate after task 06 is 1611.

Co-Authored-By: Grok 4.6 <noreply@x.ai>
2026-10-03 21:39:31 -07:00

427 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.<name>]`. 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::<u16>()`. 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<String>, env: Option<String>, file: Option<PathBuf> }` in `bxcore::secrets` |
| `SecretSpec::source` | `source(&self) -> Result<SecretSource, String>` |
| `bxcore::secrets::load` | `load(role: &str, name: &str, source: &SecretSource, env: &dyn Fn(&str) -> Option<OsString>) -> Result<Loaded, SecretError>` |
| `Loaded` | `{ secret: Secret, warning: Option<String> }` |
| `SecretError` | `Display` names the secret and `docs/runbook.md#secret-unavailable` |
| `Config::load` | `load(path: &Path) -> Result<Config, ConfigError>` |
| `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<u16, D::Error> {
#[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::<u16>().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<OsString>,
) -> Result<Option<Loaded>, 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<crate::consult::Consult>,
#[serde(default)]
pub secrets: BTreeMap<String, SecretSpec>,
```
**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.