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>
16 KiB
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.
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 testscrates/brokerd/tests/config_consult.rsandcrates/brokerd/tests/consult_secret.rs. - Append, from
docs/plans/M6b/files/runbook-consult.md, todocs/runbook.md. Both headings go in now soscripts/check-runbook.shacceptsSECRET_POINTER; task 05 adds theconsult-not-configuredpointer 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, runcargo buildonce soCargo.lockupdates.
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_httpacceptshttp://127.0.0.1orhttp://localhost, optional port 1–65535 and path.https://, a non-loopback host, a userinfo (@), or port0is an error. Default port is 80. A URL with no path uses"/". - Temperature. TOML
temperatureis anf64in 0..=0.6, or the integer0. Any other integer is invalid (must be from 0 to 0.6). Store(v * 1000.0).round()asu16viaformat!("{milli:.0}").parse::<u16>(). Default 600 (0.6).ConfigstaysEq; do not storef64onConsult. #[serde(default)]on the newConfigfields.an_empty_file_gets_every_defaultinconfig.rscompares a loaded empty file toConfig::default(). Missing tables areconsult: Noneandsecrets: {}.- The only
Config { }literal iscrates/brokerd/tests/support/rig.rs. Addconsult: None, secrets: Default::default(). Other tests load TOML. - Secrets. Same
SecretSpecasgatewayd: exactly one ofcredential/env/file.loadisbxcore::secrets::load. Role string"brokerd". Load the consult secret only when[consult]is present; absent table isOk(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> }inbxcore::secretsSecretSpec::sourcesource(&self) -> Result<SecretSource, String>bxcore::secrets::loadload(role: &str, name: &str, source: &SecretSource, env: &dyn Fn(&str) -> Option<OsString>) -> Result<Loaded, SecretError>Loaded{ secret: Secret, warning: Option<String> }SecretErrorDisplaynames the secret anddocs/runbook.md#secret-unavailableConfig::loadload(path: &Path) -> Result<Config, ConfigError>ConfigError::InvalidInvalid(PathBuf, String)ConfigError::ParseParse(PathBuf, toml::de::Error)
The edits, exactly
crates/brokerd/Cargo.toml
C1. The line
bxcore.workspace = true
becomes
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.
//! `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
use serde::Deserialize;
use std::path::{Path, PathBuf};
become
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:
#[serde(default)]
pub consult: Option<crate::consult::Consult>,
#[serde(default)]
pub secrets: BTreeMap<String, SecretSpec>,
F3. In Config::load, immediately before Ok(config), add:
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
gateway: None,
memory: Default::default(),
};
become
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.
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 (E0432unresolvedbrokerd::consult). -
3. The edits. C1 (then
cargo build), L1, writeconsult.rs, F1–F3, R1, B1.cargo check -p brokerdprints no error and no warning.wc -l crates/brokerd/src/consult.rsandwc -l crates/brokerd/src/config.rseach ≤ 500. -
4. The tests.
cargo test -p brokerd --test config_consult --test consult_secret --test config: every test passes, 6 inconfig_consult, 3 inconsult_secret.an_empty_file_gets_every_defaultstill passes. -
5. Run the gate.
cargo fmt --all, thenmake gate, timeout 600000 ms. Last linegate: ok. Test count 1602 (1593 before, plus 9). Aftergate: ok, do not add uptest result:lines. -
6. Log and commit. Task cell
M6b/03-brokerd-consult-config. Read only the first 12 lines ofdocs/implementer-log.md; insert the row under the header. Stage withgit add crates/brokerd Cargo.lock docs/runbook.md docs/implementer-log.md, then commit asAGENTS.mdsays.
Done when
make gateprintsgate: okwith 1602 tests.git diff HEAD~1 --statlists onlycrates/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, andCargo.lockif it changed.
Stop and report if
- The branch check fails; a "before" block is missing or duplicated; step 2 compiles; you
edit
tests/config.rsor addToolName::Consult;cargo checkwarns; existingconfigtests 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.