From 9a8d9b5d287cbfb921b565e4b02d670d15109a93 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 17:43:22 -0400 Subject: [PATCH 01/27] feat(policy): add review.reviewers to the settings schema The reviewer list is the first setting that names outside providers, so it enters the schema with its grammar, its show text and its place in policy.effective before the interview writes it. --- .../src/policy/config_command/file.rs | 10 +- .../src/policy/config_command/set.rs | 3 +- .../src/policy/config_command/show.rs | 4 + .../src/policy/config_command/tests.rs | 77 +++++++++++++ crates/baley-core/src/policy/merge.rs | 3 + crates/baley-core/src/policy/mod.rs | 4 +- crates/baley-core/src/policy/parse.rs | 43 ++++++- .../baley-core/src/policy/recorded/event.rs | 2 +- .../baley-core/src/policy/recorded/tests.rs | 11 ++ crates/baley-core/src/policy/schema.rs | 21 +++- crates/baley-core/src/policy/tests.rs | 105 +++++++++++++++++- 11 files changed, 269 insertions(+), 14 deletions(-) diff --git a/crates/baley-core/src/policy/config_command/file.rs b/crates/baley-core/src/policy/config_command/file.rs index 150b9f46..0a98efa7 100644 --- a/crates/baley-core/src/policy/config_command/file.rs +++ b/crates/baley-core/src/policy/config_command/file.rs @@ -74,9 +74,9 @@ fn child<'t>(table: &'t mut Table, key: &str) -> &'t mut Table { slot.as_table_mut().expect("the slot holds a table") } -/// The TOML type each kind reads back: a boolean, or a string for a rung -/// name, a model name, a remote name, an `https://` address and an -/// `on_protected` name. +/// The TOML type each kind reads back: a boolean, an array of strings for a +/// branch or reviewer list, or a string for a rung name, a model name, a +/// remote name, an `https://` address and an `on_protected` name. fn toml_value(value: &Value) -> TomlValue { match value { Value::Bool(value) => TomlValue::Boolean(*value), @@ -85,7 +85,7 @@ fn toml_value(value: &Value) -> TomlValue { Value::RemoteName(name) => TomlValue::String(name.clone()), Value::OnProtected(on) => TomlValue::String(on.name().to_owned()), Value::HttpsAddress(address) => TomlValue::String(address.clone()), - Value::BranchList(names) => { + Value::BranchList(names) | Value::ReviewerList(names) => { TomlValue::Array(names.iter().cloned().map(TomlValue::String).collect()) } } @@ -121,7 +121,7 @@ pub(super) fn value_text(value: &Value) -> String { Value::Bool(value) => value.to_string(), Value::Rung(rung) => format!("\"{}\"", rung.name()), Value::OnProtected(on) => format!("\"{}\"", on.name()), - Value::BranchList(names) => { + Value::BranchList(names) | Value::ReviewerList(names) => { let items: Vec = names.iter().map(|name| quoted(name)).collect(); format!("[{}]", items.join(", ")) } diff --git a/crates/baley-core/src/policy/config_command/set.rs b/crates/baley-core/src/policy/config_command/set.rs index a181575a..61dd6088 100644 --- a/crates/baley-core/src/policy/config_command/set.rs +++ b/crates/baley-core/src/policy/config_command/set.rs @@ -125,6 +125,7 @@ impl fmt::Display for SetRefusal { format!("one of {}", names.join(", ")) } Kind::BranchList => "a list of branch names".to_owned(), + Kind::ReviewerList => "a list of host, openai or deepseek".to_owned(), Kind::HttpsAddress => { "an https:// address with no spaces, query or fragment".to_owned() } @@ -188,7 +189,7 @@ fn convert(kind: Kind, text: &str) -> Option { Kind::RemoteName => (!text.is_empty()).then(|| Value::RemoteName(text.to_owned())), Kind::OnProtected => OnProtected::parse(text).map(Value::OnProtected), // A list is edited in `baley.toml`, so no command-line text is one. - Kind::BranchList => None, + Kind::BranchList | Kind::ReviewerList => None, Kind::HttpsAddress => is_https_address(text).then(|| Value::HttpsAddress(text.to_owned())), } } diff --git a/crates/baley-core/src/policy/config_command/show.rs b/crates/baley-core/src/policy/config_command/show.rs index 16d66b71..49df7de5 100644 --- a/crates/baley-core/src/policy/config_command/show.rs +++ b/crates/baley-core/src/policy/config_command/show.rs @@ -156,6 +156,7 @@ fn kind_text(kind: Kind) -> &'static str { Kind::RemoteName => "remote name", Kind::OnProtected => "one of ask, refuse or allow", Kind::BranchList => "list of branch names", + Kind::ReviewerList => "list of host, openai or deepseek", Kind::HttpsAddress => "https:// address", } } @@ -177,6 +178,9 @@ fn default_text(default: Builtin) -> String { Builtin::BranchList(names) => value_text(&Value::BranchList( names.iter().map(|name| (*name).to_owned()).collect(), )), + Builtin::ReviewerList(names) => value_text(&Value::ReviewerList( + names.iter().map(|name| (*name).to_owned()).collect(), + )), } } diff --git a/crates/baley-core/src/policy/config_command/tests.rs b/crates/baley-core/src/policy/config_command/tests.rs index 25017d7f..d95f507a 100644 --- a/crates/baley-core/src/policy/config_command/tests.rs +++ b/crates/baley-core/src/policy/config_command/tests.rs @@ -1864,3 +1864,80 @@ fn a_config_set_source_with_a_query_or_fragment_the_parser_would_refuse_is_caugh "[updates]\nsource = \"https://dl.example/dev\"\n" ); } + +fn reviewers(names: &[&str]) -> TypedPair { + pair( + "review.reviewers", + None, + Value::ReviewerList(names.iter().map(|name| name.to_string()).collect()), + ) +} + +#[test] +fn a_reviewer_list_rendered_as_anything_but_a_toml_array_is_caught() { + let bytes = render_file( + Schema::standard(), + FileLayer::Global, + None, + &[reviewers(&["host", "openai"])], + ) + .unwrap(); + let read = |bytes: Vec| { + let file = SettingsFile { + path: "/c/config.toml".into(), + bytes, + digest: "d".into(), + }; + parse_layer(&file, FileLayer::Global, Schema::standard()).expect("the render reads back") + }; + let layer = read(bytes); + let written: Vec<_> = layer + .values + .iter() + .filter(|written| written.name == "review.reviewers") + .collect(); + assert_eq!(written.len(), 1); + assert_eq!(written[0].host, None); + assert_eq!( + written[0].value, + Value::ReviewerList(vec!["host".into(), "openai".into()]) + ); + + let empty = render_file( + Schema::standard(), + FileLayer::Global, + None, + &[reviewers(&[])], + ) + .unwrap(); + let layer = read(empty); + let written: Vec<_> = layer + .values + .iter() + .filter(|written| written.name == "review.reviewers") + .collect(); + assert_eq!(written.len(), 1); + assert_eq!(written[0].value, Value::ReviewerList(Vec::new())); +} + +#[test] +fn a_reviewer_list_shown_without_its_kind_default_or_scope_is_caught() { + let layers = ShowLayers { + global: None, + working: None, + head: None, + }; + let lines = render_show(&ShowRequest { + schema: Schema::standard(), + host: None, + names: &["review.reviewers"], + layers: &layers, + pending: None, + global_path: std::path::Path::new("/c/config.toml"), + project_path: None, + }); + assert_eq!( + lines[0], + "review.reviewers: kind list of host, openai or deepseek, default [\"host\"], scope both" + ); +} diff --git a/crates/baley-core/src/policy/merge.rs b/crates/baley-core/src/policy/merge.rs index 77d4a3d7..e34e9a9d 100644 --- a/crates/baley-core/src/policy/merge.rs +++ b/crates/baley-core/src/policy/merge.rs @@ -157,6 +157,9 @@ pub fn merge( schema::Default::BranchList(names) => Some(Value::BranchList( names.iter().map(|name| (*name).to_owned()).collect(), )), + schema::Default::ReviewerList(names) => Some(Value::ReviewerList( + names.iter().map(|name| (*name).to_owned()).collect(), + )), }; let source = Source { layer: Layer::Default, diff --git a/crates/baley-core/src/policy/mod.rs b/crates/baley-core/src/policy/mod.rs index dcb19be8..5e704d4c 100644 --- a/crates/baley-core/src/policy/mod.rs +++ b/crates/baley-core/src/policy/mod.rs @@ -29,4 +29,6 @@ pub use route::{ AcceptedNames, Route, RouteRefusal, RouteRequest, RungMap, SettingSource, UNKNOWN_MODEL, resolve_route, }; -pub use schema::{Default, Entry, Host, Kind, OnProtected, Role, Rung, Schema, Scope}; +pub use schema::{ + Default, Entry, Host, Kind, OnProtected, REVIEWER_NAMES, Role, Rung, Schema, Scope, +}; diff --git a/crates/baley-core/src/policy/parse.rs b/crates/baley-core/src/policy/parse.rs index 84f6c028..b10b9243 100644 --- a/crates/baley-core/src/policy/parse.rs +++ b/crates/baley-core/src/policy/parse.rs @@ -10,7 +10,9 @@ use toml::de::{DeString, DeTable, DeValue}; use super::merge::FileRef; use super::project::ProjectProblem; -use super::schema::{Entry, Host, Kind, OnProtected, Rung, Schema, Scope, is_https_address}; +use super::schema::{ + Entry, Host, Kind, OnProtected, REVIEWER_NAMES, Rung, Schema, Scope, is_https_address, +}; /// The code of every refusal that leaves the policy unbuilt. pub const CONFIG_UNAVAILABLE: &str = "config-unavailable"; @@ -50,6 +52,9 @@ pub enum Value { OnProtected(OnProtected), /// Branch names as written, in file order. May be empty. BranchList(Vec), + /// Reviewer names as written, in file order, repeats included. May be + /// empty. Each is one of [`REVIEWER_NAMES`]. + ReviewerList(Vec), /// An `https://` address. HttpsAddress(String), } @@ -218,6 +223,8 @@ pub enum Expected { OnProtected, /// A list of branch names. BranchList, + /// A list of reviewer names. + ReviewerList, /// An `https://` address. HttpsAddress, } @@ -230,6 +237,7 @@ impl From for Expected { Kind::RemoteName => Expected::RemoteName, Kind::OnProtected => Expected::OnProtected, Kind::BranchList => Expected::BranchList, + Kind::ReviewerList => Expected::ReviewerList, Kind::HttpsAddress => Expected::HttpsAddress, } } @@ -296,6 +304,7 @@ fn describe(f: &mut fmt::Formatter<'_>, path: &str, fault: &Fault) -> fmt::Resul Expected::RemoteName => "a remote name".to_owned(), Expected::OnProtected => format!("one of {}", on_protected_names()), Expected::BranchList => "a list of branch names".to_owned(), + Expected::ReviewerList => format!("a list of {}", reviewer_names()), Expected::HttpsAddress => "an https:// address".to_owned(), }; write!( @@ -326,6 +335,11 @@ fn describe(f: &mut fmt::Formatter<'_>, path: &str, fault: &Fault) -> fmt::Resul Kind::BranchList => { f.write_str("holds an empty branch name; remove it or write a name") } + Kind::ReviewerList => write!( + f, + "holds \"{written}\", which is not {}; write one of them or remove it", + reviewer_names() + ), Kind::HttpsAddress => write!( f, "is \"{written}\"; write an https:// address without a query or fragment (? or #) or remove the line" @@ -417,6 +431,14 @@ fn rungs() -> String { names.join(", ") } +/// The accepted reviewer names in prose: `host, openai or deepseek`. +fn reviewer_names() -> String { + match REVIEWER_NAMES.split_last() { + Some((last, rest)) => format!("{} or {last}", rest.join(", ")), + None => String::new(), + } +} + fn on_protected_names() -> String { let names: Vec<&str> = OnProtected::ALL .into_iter() @@ -584,6 +606,25 @@ fn typed(kind: Kind, value: &DeValue<'_>) -> Result { } Ok(Value::BranchList(names)) } + Kind::ReviewerList => { + let DeValue::Array(items) = value else { + return Err(wrong()); + }; + let mut names = Vec::with_capacity(items.len()); + for item in items.iter() { + let at = Some(item.span().start); + let Some(name) = item.get_ref().as_str() else { + // Named by its type: the element has no text to quote. + let found = item.get_ref().type_str(); + return Err(Mismatch::Grammar(format!("{} {found}", article(found)), at)); + }; + if !REVIEWER_NAMES.contains(&name) { + return Err(Mismatch::Grammar(name.to_owned(), at)); + } + names.push(name.to_owned()); + } + Ok(Value::ReviewerList(names)) + } Kind::HttpsAddress => { let written = value.as_str().ok_or_else(wrong)?; if is_https_address(written) { diff --git a/crates/baley-core/src/policy/recorded/event.rs b/crates/baley-core/src/policy/recorded/event.rs index 361e7f41..1567e4d3 100644 --- a/crates/baley-core/src/policy/recorded/event.rs +++ b/crates/baley-core/src/policy/recorded/event.rs @@ -110,7 +110,7 @@ fn value_json(value: Option<&Value>) -> Json { Some(Value::ModelName(name)) => name.clone().into(), Some(Value::RemoteName(name)) => name.clone().into(), Some(Value::OnProtected(on)) => on.name().into(), - Some(Value::BranchList(names)) => names.clone().into(), + Some(Value::BranchList(names) | Value::ReviewerList(names)) => names.clone().into(), Some(Value::HttpsAddress(address)) => address.clone().into(), } } diff --git a/crates/baley-core/src/policy/recorded/tests.rs b/crates/baley-core/src/policy/recorded/tests.rs index 78157e3f..399a6d48 100644 --- a/crates/baley-core/src/policy/recorded/tests.rs +++ b/crates/baley-core/src/policy/recorded/tests.rs @@ -628,3 +628,14 @@ fn the_branch_list_recorded_as_a_string_or_dropped_is_caught() { json!([]) ); } + +#[test] +fn a_reviewer_list_left_out_of_the_recorded_policy_is_caught() { + let merged = effective_policy(Schema::standard(), None, None, None).expect("no files"); + let payload = payload(&merged, 1); + assert_eq!(payload["values"]["review.reviewers"], json!(["host"])); + assert_eq!( + payload["sources"]["review.reviewers"], + json!({"layer": "default"}) + ); +} diff --git a/crates/baley-core/src/policy/schema.rs b/crates/baley-core/src/policy/schema.rs index df61a8e0..71155953 100644 --- a/crates/baley-core/src/policy/schema.rs +++ b/crates/baley-core/src/policy/schema.rs @@ -179,12 +179,19 @@ pub enum Kind { OnProtected, /// A list of branch names, possibly empty. Each is a non-blank string. BranchList, + /// A list of reviewer names, possibly empty. Each is exactly one of + /// [`REVIEWER_NAMES`], case not folded; repeats are kept as written. + ReviewerList, /// An `https://` address: the scheme, then at least one character, with no /// whitespace, control character, query or fragment (`?` or `#`). Whether /// anything answers there is the fetcher's question, not the schema's. HttpsAddress, } +/// The names a reviewer list may hold (design 0003 section 9): the host's own +/// reviewer and the two outside providers. +pub const REVIEWER_NAMES: [&str; 3] = ["host", "openai", "deepseek"]; + /// Whether `text` is an `https://` address the schema accepts (design 0012 /// section 9): the scheme, at least one more character, and no whitespace, /// control character, query or fragment (`?` or `#`) anywhere. @@ -208,6 +215,8 @@ pub enum Default { OnProtected(OnProtected), /// A static list of branch names. BranchList(&'static [&'static str]), + /// A static list of reviewer names. + ReviewerList(&'static [&'static str]), } impl Default { fn fits(self, kind: Kind) -> bool { @@ -218,6 +227,7 @@ impl Default { | (Default::Rung(_), Kind::Rung) | (Default::OnProtected(_), Kind::OnProtected) | (Default::BranchList(_), Kind::BranchList) + | (Default::ReviewerList(_), Kind::ReviewerList) ) } } @@ -267,8 +277,8 @@ impl Schema { } /// The schema: each role's model and effort, `escalate_on_failure`, - /// `git.remote`, the guard's settings under `git` and the two `updates` - /// settings. + /// `git.remote`, the guard's settings under `git`, the two `updates` + /// settings and `review.reviewers`. pub fn standard() -> &'static Schema { static STANDARD: OnceLock = OnceLock::new(); STANDARD.get_or_init(|| { @@ -344,6 +354,13 @@ impl Schema { scope: Scope::Global, owner: "0012", }); + entries.push(Entry { + name: "review.reviewers".into(), + kind: Kind::ReviewerList, + default: Default::ReviewerList(&["host"]), + scope: Scope::Both, + owner: "0008", + }); Schema::new(entries) }) } diff --git a/crates/baley-core/src/policy/tests.rs b/crates/baley-core/src/policy/tests.rs index 715ad3fb..2721461a 100644 --- a/crates/baley-core/src/policy/tests.rs +++ b/crates/baley-core/src/policy/tests.rs @@ -131,7 +131,7 @@ fn standard_schema_holds_every_entry_with_its_kind_default_scope_and_owner() { ("escalate_on_failure", Kind::Bool, Bool(false)), ]; let schema = Schema::standard(); - assert_eq!(schema.entries().len(), 19); + assert_eq!(schema.entries().len(), 20); for (name, kind, default) in expected { let entry = schema .get(name) @@ -697,7 +697,7 @@ fn an_empty_file_and_no_file_both_give_the_defaults_but_only_the_file_has_a_dige let with = effective_policy(Schema::standard(), None, Some(&empty), None).unwrap(); let without = effective_policy(Schema::standard(), None, None, None).unwrap(); for policy in [&with, &without] { - assert_eq!(policy.settings.len(), 19); + assert_eq!(policy.settings.len(), 20); assert!( policy .settings @@ -1459,7 +1459,7 @@ fn an_update_setting_missing_or_open_to_project_files_is_caught() { (source.kind, source.default, source.scope, source.owner), (Kind::HttpsAddress, Builtin::Absent, Scope::Global, "0012") ); - assert_eq!(schema.entries().len(), 19); + assert_eq!(schema.entries().len(), 20); } #[test] @@ -1514,3 +1514,102 @@ fn an_update_source_with_a_query_or_fragment_that_swallows_the_platform_path_is_ ); } } + +#[test] +fn a_reviewer_list_missing_from_the_schema_or_off_its_default_or_scope_is_caught() { + let schema = Schema::standard(); + let reviewers = schema + .get("review.reviewers") + .expect("review.reviewers is missing"); + assert_eq!( + ( + reviewers.kind, + reviewers.default, + reviewers.scope, + reviewers.owner + ), + ( + Kind::ReviewerList, + Builtin::ReviewerList(&["host"]), + Scope::Both, + "0008" + ) + ); + let policy = standard(None, None, None).unwrap(); + let effective = value_of(&policy, "review.reviewers"); + assert_eq!( + effective.value, + Some(Value::ReviewerList(vec!["host".into()])) + ); + assert_eq!(effective.source, default_source()); +} + +fn reviewer_names(policy: &EffectivePolicy) -> Option> { + match &value_of(policy, "review.reviewers").value { + Some(Value::ReviewerList(names)) => Some(names.clone()), + _ => None, + } +} + +#[test] +fn a_reviewer_list_read_with_an_unknown_reviewer_or_the_wrong_type_is_caught() { + let strings = |names: &[&str]| Some(names.iter().map(|n| n.to_string()).collect::>()); + for (line, expected) in [ + ( + "reviewers = [\"host\", \"openai\", \"deepseek\"]", + strings(&["host", "openai", "deepseek"]), + ), + ("reviewers = []", strings(&[])), + ] { + let policy = standard(None, Some(&format!("[review]\n{line}\n")), None).unwrap(); + assert_eq!(reviewer_names(&policy), expected, "{line}"); + assert_eq!( + value_of(&policy, "review.reviewers").source.layer, + Layer::Global, + "{line}" + ); + } + for line in [ + "reviewers = [\"anthropic\"]", + "reviewers = [\"OpenAI\"]", + "reviewers = [\"host\", 7]", + ] { + let refusal = global_fault(&format!("[review]\n{line}\n")); + assert_eq!(refusal.code(), "config-unavailable", "{line}"); + assert!( + matches!( + refusal.fault, + Fault::OutsideGrammar { + kind: Kind::ReviewerList, + line: 2, + .. + } + ), + "{line}: {refusal:?}" + ); + assert!( + refusal.to_string().contains("review.reviewers"), + "{line}: {refusal}" + ); + } + let wrong = global_fault("[review]\nreviewers = \"openai\"\n"); + assert_eq!(wrong.code(), "config-unavailable"); + assert!( + matches!( + wrong.fault, + Fault::WrongType { + expected: Expected::ReviewerList, + found: "string", + line: 2, + .. + } + ), + "{wrong:?}" + ); + assert!( + wrong + .to_string() + .ends_with("review.reviewers is a string, not a list of host, openai or deepseek"), + "{wrong}" + ); +} From c43f2876a9f9afcb672f8752a29c31c138d8e963 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 17:45:39 -0400 Subject: [PATCH 02/27] feat(providers): compile the provider warning and register providers.acknowledged The warning is one constant with a version and a digest, so a changed wording moves the digest and asks every owner again. The event stays off the views, so the view set holds at 8 and older binaries keep writing user. --- crates/baley/src/ledger/open.rs | 51 ++++++++++++-- crates/baley/src/lib.rs | 2 + crates/baley/src/providers/event.rs | 96 +++++++++++++++++++++++++++ crates/baley/src/providers/mod.rs | 12 ++++ crates/baley/src/providers/warning.rs | 19 ++++++ 5 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 crates/baley/src/providers/event.rs create mode 100644 crates/baley/src/providers/mod.rs create mode 100644 crates/baley/src/providers/warning.rs diff --git a/crates/baley/src/ledger/open.rs b/crates/baley/src/ledger/open.rs index 53a0c5d2..35c5fa62 100644 --- a/crates/baley/src/ledger/open.rs +++ b/crates/baley/src/ledger/open.rs @@ -14,15 +14,17 @@ use baley_store_sqlite::Options; use crate::install::event::register_install_events; use crate::install::view::InstallProjector; +use crate::providers::event::register_provider_events; use crate::update::events::register_update_events; /// Registers exactly the anchor types, `project.initialized`, the four /// `models.*` types, `policy.effective`, `checkout.seen`, `capture.recorded`, /// `guard.answered`, `guard.policy_recorded`, `install.recorded`, -/// `update.checked` and `update.failed` understood by the CLI, and declares -/// view set version 8: `capture checkout claim_scope guard guard_policy -/// install model_catalog policy request`. The install view holds only install -/// records. Update outcomes are read from their stream and have no projector. +/// `update.checked`, `update.failed` and `providers.acknowledged` understood by +/// the CLI, and declares view set version 8: `capture checkout claim_scope +/// guard guard_policy install model_catalog policy request`. The install view +/// holds only install records. Update outcomes and provider acknowledgements +/// are read from their streams and have no projector. /// A project brought to view set 8 is read-only for older binaries. /// The startup `quick_check` is off for command-line callers. pub(crate) fn options() -> Options { @@ -36,6 +38,7 @@ pub(crate) fn options() -> Options { register_guard_events(&mut registry).expect("unique guard types"); register_install_events(&mut registry).expect("unique install types"); register_update_events(&mut registry).expect("unique update types"); + register_provider_events(&mut registry).expect("unique provider types"); Options { schema: Box::new(registry), projectors: vec![ @@ -505,6 +508,46 @@ pub(crate) mod tests { ); } } + + // The acknowledgement stays on its stream, outside every view, so the view + // set does not rise and an older binary keeps writing `user`. + #[test] + fn the_acknowledgement_event_left_unregistered_or_given_a_view_is_caught() { + use crate::providers::event::{PROVIDERS_ACKNOWLEDGED, PROVIDERS_ACKNOWLEDGED_VERSION}; + + for opened in [ + options(), + server_options(), + guard_options(Duration::from_millis(1_500)), + ] { + assert!( + opened + .schema + .reads(PROVIDERS_ACKNOWLEDGED, PROVIDERS_ACKNOWLEDGED_VERSION) + ); + assert!( + !opened + .schema + .reads(PROVIDERS_ACKNOWLEDGED, PROVIDERS_ACKNOWLEDGED_VERSION + 1) + ); + assert_eq!(opened.view_set_version, NonZeroU32::new(8).unwrap()); + let mut names = view_names(&opened); + names.sort(); + assert_eq!( + names, + [ + "capture", + "checkout", + "guard", + "guard_policy", + "install", + "model_catalog", + "policy" + ] + ); + } + } + /// One capture in `project`, recorded as the capture command does. fn record_capture( store: &baley_store_sqlite::SqliteStore, diff --git a/crates/baley/src/lib.rs b/crates/baley/src/lib.rs index 3e0485b8..823836e0 100644 --- a/crates/baley/src/lib.rs +++ b/crates/baley/src/lib.rs @@ -85,6 +85,8 @@ pub mod models; pub mod policy_step; /// Which paths the guard protects, and the decisions that judge a tool call's paths against them. pub mod protected_paths; +/// Outside review providers: the compiled risk warning, the owner's recorded acknowledgement of it and the owner identity it names. +pub mod providers; /// Replaces a settings file whole, refusing when it changed since it was read. pub mod replace; /// The two settings files as bytes: the global file's path, one reader and its capped form for the guard. diff --git a/crates/baley/src/providers/event.rs b/crates/baley/src/providers/event.rs new file mode 100644 index 00000000..4f81df14 --- /dev/null +++ b/crates/baley/src/providers/event.rs @@ -0,0 +1,96 @@ +//! The `providers.acknowledged` event (design 0003 section 6). +//! +//! It goes on stream `providers` in the per-user project `user`. No view keeps +//! a document for it: the reuse check and Build 4 read the stream directly, +//! as the update events are read, so the view set stays where it is. The +//! builder here is pure; recording the event is the caller's. + +use baley_core::catalog::Provider; +use baley_core::{Registry, RegistryError}; +use baley_store::{NewEvent, StreamName}; +use serde_json::json; + +use super::warning::{WARNING_VERSION, warning_sha256}; + +/// The owner confirmed the warning for these providers: +/// `{providers, warning_version, warning_sha256, owner, acknowledged_at}`. +pub const PROVIDERS_ACKNOWLEDGED: &str = "providers.acknowledged"; +/// The current payload version of the event. +pub const PROVIDERS_ACKNOWLEDGED_VERSION: u32 = 1; +/// The stream the event goes on in `user`. +pub const PROVIDERS_STREAM: &str = "providers"; + +/// Registers `providers.acknowledged` at version 1, with no upcasters. +pub fn register_provider_events(registry: &mut Registry) -> Result<(), RegistryError> { + registry.register(PROVIDERS_ACKNOWLEDGED, PROVIDERS_ACKNOWLEDGED_VERSION, []) +} + +/// The `providers.acknowledged` event for the confirmed `providers`. +/// +/// The payload lists each provider once, in catalog order, whatever the order +/// or repeats of the argument. It names the compiled warning's version and +/// SHA-256, the owner identity text and the supplied time. It holds no +/// credential or environment value. +pub fn acknowledged_event(providers: &[Provider], owner: &str, at: &str) -> NewEvent { + let names: Vec<&str> = Provider::ALL + .into_iter() + .filter(|provider| providers.contains(provider)) + .map(Provider::name) + .collect(); + NewEvent { + stream: StreamName(PROVIDERS_STREAM.into()), + type_name: PROVIDERS_ACKNOWLEDGED.into(), + type_version: PROVIDERS_ACKNOWLEDGED_VERSION, + git: None, + payload: json!({ + "providers": names, + "warning_version": WARNING_VERSION, + "warning_sha256": warning_sha256(), + "owner": owner, + "acknowledged_at": at, + }), + attachments: vec![], + } +} + +#[cfg(test)] +mod tests { + use sha2::{Digest, Sha256}; + + use super::*; + + /// Design 0003 section 5's warning paragraph, written out here so the + /// expected digest does not come from the constant under test. + const DESIGN_WARNING: &str = "Baley never reads your API keys. Keys live in your environment, where every program Claude Code starts can see them, agents and subagents included. Nothing scrubs a key a command prints. Your code goes to the chosen provider under its terms."; + + fn sha256_hex(text: &str) -> String { + Sha256::digest(text.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() + } + + #[test] + fn an_acknowledgement_listing_providers_out_of_catalog_order_or_twice_is_caught() { + let event = acknowledged_event( + &[Provider::DeepSeek, Provider::OpenAi, Provider::DeepSeek], + "Ada Owner", + "2026-10-10T09:00:00Z", + ); + assert_eq!(event.stream, StreamName("providers".into())); + assert_eq!(event.type_name, "providers.acknowledged"); + assert_eq!(event.type_version, 1); + assert!(event.git.is_none()); + assert!(event.attachments.is_empty()); + assert_eq!( + event.payload, + json!({ + "providers": ["openai", "deepseek"], + "warning_version": 1, + "warning_sha256": sha256_hex(DESIGN_WARNING), + "owner": "Ada Owner", + "acknowledged_at": "2026-10-10T09:00:00Z", + }) + ); + } +} diff --git a/crates/baley/src/providers/mod.rs b/crates/baley/src/providers/mod.rs new file mode 100644 index 00000000..320957c7 --- /dev/null +++ b/crates/baley/src/providers/mod.rs @@ -0,0 +1,12 @@ +//! Outside review providers: the compiled risk warning, the owner's recorded +//! acknowledgement of it and the owner identity it names (design 0003 +//! section 5, CFG-R28). +//! +//! Baley never reads, stores or sends a provider key (CFG-R24). Nothing here +//! touches one, and the acknowledgement records no credential or environment +//! value. + +/// The `providers.acknowledged` event and its payload. +pub mod event; +/// The compiled provider warning, its version and its digest. +pub mod warning; diff --git a/crates/baley/src/providers/warning.rs b/crates/baley/src/providers/warning.rs new file mode 100644 index 00000000..1e3a65c2 --- /dev/null +++ b/crates/baley/src/providers/warning.rs @@ -0,0 +1,19 @@ +//! The provider risk warning, compiled in (design 0003 section 5). + +use crate::store::model::digest; + +/// The warning the owner types `yes` to for each outside provider, worded as +/// design 0003 section 5 words it. +/// +/// Any change of wording raises [`WARNING_VERSION`]. A changed text also +/// moves [`warning_sha256`], so a stored acknowledgement no longer matches +/// and every owner is asked again (CFG-R28). +pub const WARNING_TEXT: &str = "Baley never reads your API keys. Keys live in your environment, where every program Claude Code starts can see them, agents and subagents included. Nothing scrubs a key a command prints. Your code goes to the chosen provider under its terms."; + +/// The version of [`WARNING_TEXT`]; it rises with any change of wording. +pub const WARNING_VERSION: u32 = 1; + +/// The lower-case hex SHA-256 of [`WARNING_TEXT`]'s UTF-8 bytes. +pub fn warning_sha256() -> String { + digest(WARNING_TEXT.as_bytes()) +} From d2aa3b1906e7a5b5c69982fe55de5077d1e6e8ce Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 17:52:29 -0400 Subject: [PATCH 03/27] feat(providers): read the owner's name from git for the acknowledgement An acknowledgement without an owner is not recorded, so every failed read of user.name refuses owner-identity-missing and says what git did. The read goes through the registered git seam under its own caller. --- crates/baley/src/git_process.rs | 6 +- crates/baley/src/git_process/tests.rs | 7 ++ crates/baley/src/providers/identity.rs | 125 +++++++++++++++++++++++++ crates/baley/src/providers/mod.rs | 9 ++ 4 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 crates/baley/src/providers/identity.rs diff --git a/crates/baley/src/git_process.rs b/crates/baley/src/git_process.rs index ba45b9dd..5c55f780 100644 --- a/crates/baley/src/git_process.rs +++ b/crates/baley/src/git_process.rs @@ -35,6 +35,9 @@ pub enum Caller { ProjectHead, /// A checkout's root commit and remote URL, read for checkout admission. CheckoutFacts, + /// The owner's name, `git config --get user.name`, read as the identity + /// on a provider acknowledgement. + OwnerIdentity, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -76,7 +79,8 @@ pub fn deadline(caller: Caller) -> Deadline { | Caller::LandingGit | Caller::AnchorForge | Caller::ProjectHead - | Caller::CheckoutFacts => Deadline::Exact(OTHER_GIT_DEADLINE), + | Caller::CheckoutFacts + | Caller::OwnerIdentity => Deadline::Exact(OTHER_GIT_DEADLINE), } } diff --git a/crates/baley/src/git_process/tests.rs b/crates/baley/src/git_process/tests.rs index 1bff4524..bfef8a9f 100644 --- a/crates/baley/src/git_process/tests.rs +++ b/crates/baley/src/git_process/tests.rs @@ -98,6 +98,12 @@ fn git_subprocesses_run_under_a_deadline() { "git rev-list --max-parents=0 HEAD", 60, ), + ( + Caller::OwnerIdentity, + &["config", "--get", "user.name"], + "git config --get user.name", + 60, + ), ]; for &(caller, args, command, seconds) in rows { let error = finish( @@ -149,6 +155,7 @@ fn registered_callers_select_their_deadlines() { Caller::AnchorForge, Caller::ProjectHead, Caller::CheckoutFacts, + Caller::OwnerIdentity, ] { assert_eq!( deadline(caller), diff --git a/crates/baley/src/providers/identity.rs b/crates/baley/src/providers/identity.rs new file mode 100644 index 00000000..6641b467 --- /dev/null +++ b/crates/baley/src/providers/identity.rs @@ -0,0 +1,125 @@ +//! The owner identity an acknowledgement names: git's `user.name` (design 0003 +//! section 6, design 0012 HST-R10). `owner.name` is not read here, since that +//! setting belongs to the approval build. + +use crate::committed::{Ran, failed, finished}; +use crate::git_process::{self, Caller}; +use crate::ledger::display::Render; +use crate::process::{Launch, Process}; + +/// The code of a refusal when no owner name can be read from git. +pub(crate) const OWNER_IDENTITY_MISSING: &str = "owner-identity-missing"; + +/// The launch that reads the name, in the process's own working directory so +/// a repository's own `user.name` applies as git applies it. +fn name_launch() -> Launch { + git_process::launch(Caller::OwnerIdentity).args(["config", "--get", "user.name"]) +} + +/// Reads git's `user.name` through `process`. A name that cannot be had +/// refuses `owner-identity-missing`, because an acknowledgement without an +/// owner is not recorded. +pub(crate) fn read(process: &mut dyn Process) -> Result { + let launch = name_launch(); + let ran = git_process::run(&launch, process); + judge(&launch, ran).map_err(refusal) +} + +/// Interprets git's answer: the name, or why there is none. +/// +/// Exit 1 is git's answer for an unset key. Every other failure also gives no +/// name and says what git did, since an acknowledgement needs one either way. +fn judge(launch: &Launch, ran: Ran) -> Result { + if let Err(git_process::Error::Io(error)) = &ran + && error.kind() == std::io::ErrorKind::NotFound + { + return Err("git was not found, so user.name could not be read".into()); + } + let output = finished(launch, ran)?; + match output.code() { + Some(0) => {} + Some(1) => return Err("git user.name is not set".into()), + _ => return Err(failed(launch, &output)), + } + if !output.stdout_complete { + return Err("git gave incomplete output for user.name".into()); + } + let unreadable = || "git gave a user.name that is not one line of text".to_owned(); + let text = std::str::from_utf8(&output.stdout).map_err(|_| unreadable())?; + let line = text.strip_suffix('\n').unwrap_or(text); + let line = line.strip_suffix('\r').unwrap_or(line); + if line.trim().is_empty() { + return Err("git user.name is blank".into()); + } + // The name reaches the owner's terminal and the ledger, so a control + // character from a config file is refused rather than passed on. + if line.chars().any(char::is_control) { + return Err(unreadable()); + } + Ok(line.to_owned()) +} + +/// The owner's refusal for a missing name, with how to fix it. +fn refusal(reason: String) -> Render { + Render::refusal(format!( + "{OWNER_IDENTITY_MISSING}: {reason}; set it with git config --global user.name \"Your Name\" and run the interview again" + )) +} + +#[cfg(test)] +mod tests { + use std::io; + + use super::*; + use crate::git_process::{Deadline, deadline}; + use crate::process::{Output, Recorded}; + use std::time::Duration; + + #[test] + fn an_owner_name_read_with_other_git_arguments_or_kept_with_its_line_ending_is_caught() { + let mut fake = Recorded::new().out("Ada Owner\n"); + assert_eq!(read(&mut fake).unwrap(), "Ada Owner"); + assert_eq!(fake.arguments(), [["config", "--get", "user.name"]]); + let launch = fake.launch(); + assert_eq!(launch.git_caller(), Some(Caller::OwnerIdentity)); + assert_eq!(launch.timeout, Some(Duration::from_secs(60))); + assert_eq!( + deadline(Caller::OwnerIdentity), + Deadline::Exact(Duration::from_secs(60)) + ); + } + + #[test] + fn an_unset_name_or_a_missing_git_taken_as_an_owner_is_caught() { + let mut cut_short = Output::exited(0, "Ada Owner\n", ""); + cut_short.stdout_complete = false; + let cases = [ + Recorded::new().fail(1, ""), + Recorded::new().unavailable(io::ErrorKind::NotFound.into()), + Recorded::new().out(" \n"), + Recorded::new().out(b"Ada \xff\n".to_vec()), + Recorded::new().fail(128, "fatal: bad config line 1\n"), + Recorded::new().answer(cut_short), + Recorded::new().out("Ada\x1b[31m Owner\n"), + Recorded::new().out("Ada\nOwner\n"), + ]; + for (index, mut fake) in cases.into_iter().enumerate() { + let refusal = read(&mut fake).unwrap_err(); + assert_eq!(refusal.lines.len(), 1, "case {index}"); + assert!( + refusal.lines[0].starts_with("owner-identity-missing:"), + "case {index}: {}", + refusal.lines[0] + ); + assert_eq!(refusal.code, 2, "case {index}"); + assert!(refusal.error, "case {index}"); + if index == 4 { + assert!( + refusal.lines[0].contains("exited with code 128"), + "{}", + refusal.lines[0] + ); + } + } + } +} diff --git a/crates/baley/src/providers/mod.rs b/crates/baley/src/providers/mod.rs index 320957c7..19e656b8 100644 --- a/crates/baley/src/providers/mod.rs +++ b/crates/baley/src/providers/mod.rs @@ -8,5 +8,14 @@ /// The `providers.acknowledged` event and its payload. pub mod event; +/// The owner identity from git. +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "the interview reads the owner identity and is wired in next" + ) +)] +pub(crate) mod identity; /// The compiled provider warning, its version and its digest. pub mod warning; From d013ab3d20e89c5f7e5709586d3b3a8228097ef6 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 18:00:07 -0400 Subject: [PATCH 04/27] feat(providers): record the acknowledgement unless a current one covers it A rerun with the same providers records nothing, a reworded warning asks again by its version and its digest, and the append is bound to the stream version the read saw so two runs cannot both record. --- crates/baley/src/providers/event.rs | 14 +- crates/baley/src/providers/mod.rs | 28 ++ crates/baley/src/providers/record.rs | 421 +++++++++++++++++++++++++++ 3 files changed, 450 insertions(+), 13 deletions(-) create mode 100644 crates/baley/src/providers/record.rs diff --git a/crates/baley/src/providers/event.rs b/crates/baley/src/providers/event.rs index 4f81df14..c637c60b 100644 --- a/crates/baley/src/providers/event.rs +++ b/crates/baley/src/providers/event.rs @@ -55,20 +55,8 @@ pub fn acknowledged_event(providers: &[Provider], owner: &str, at: &str) -> NewE #[cfg(test)] mod tests { - use sha2::{Digest, Sha256}; - use super::*; - - /// Design 0003 section 5's warning paragraph, written out here so the - /// expected digest does not come from the constant under test. - const DESIGN_WARNING: &str = "Baley never reads your API keys. Keys live in your environment, where every program Claude Code starts can see them, agents and subagents included. Nothing scrubs a key a command prints. Your code goes to the chosen provider under its terms."; - - fn sha256_hex(text: &str) -> String { - Sha256::digest(text.as_bytes()) - .iter() - .map(|byte| format!("{byte:02x}")) - .collect() - } + use crate::providers::fixtures::{DESIGN_WARNING, sha256_hex}; #[test] fn an_acknowledgement_listing_providers_out_of_catalog_order_or_twice_is_caught() { diff --git a/crates/baley/src/providers/mod.rs b/crates/baley/src/providers/mod.rs index 19e656b8..b534adbb 100644 --- a/crates/baley/src/providers/mod.rs +++ b/crates/baley/src/providers/mod.rs @@ -17,5 +17,33 @@ pub mod event; ) )] pub(crate) mod identity; +/// Recording the acknowledgement unless a current one covers it. +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "the interview records the acknowledgement and is wired in next" + ) +)] +pub(crate) mod record; /// The compiled provider warning, its version and its digest. pub mod warning; + +/// Shared inputs for the provider tests: design 0003 section 5's warning +/// paragraph written out, so no expected digest comes from the constant under +/// test, and a SHA-256 helper that does not use the code under test. +#[cfg(test)] +pub(crate) mod fixtures { + use sha2::{Digest, Sha256}; + + /// Design 0003 section 5's warning paragraph. + pub(crate) const DESIGN_WARNING: &str = "Baley never reads your API keys. Keys live in your environment, where every program Claude Code starts can see them, agents and subagents included. Nothing scrubs a key a command prints. Your code goes to the chosen provider under its terms."; + + /// The lower-case hex SHA-256 of `text`. + pub(crate) fn sha256_hex(text: &str) -> String { + Sha256::digest(text.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() + } +} diff --git a/crates/baley/src/providers/record.rs b/crates/baley/src/providers/record.rs new file mode 100644 index 00000000..516ad7be --- /dev/null +++ b/crates/baley/src/providers/record.rs @@ -0,0 +1,421 @@ +//! Recording the provider acknowledgement unless a current one covers it +//! (design 0003 section 6, CFG-R28). +//! +//! The reads and the append are gathering over the store. Whether a stored +//! acknowledgement covers the providers now confirmed, and how a store +//! failure is worded, are judged on plain values. + +use std::collections::BTreeSet; + +use baley_core::catalog::{Provider, USER_PROJECT}; +use baley_store::{ + Actor, Admin, Command, CommandKind, Decision, Ledger, NewEvent, Observed, OutcomeKind, + PageRequest, ProjectId, Refusal, RequestId, StoreError, StreamName, request_digest, +}; +use serde_json::{Value, json}; + +use super::event::{ + PROVIDERS_ACKNOWLEDGED, PROVIDERS_ACKNOWLEDGED_VERSION, PROVIDERS_STREAM, acknowledged_event, +}; +use super::warning::{WARNING_VERSION, warning_sha256}; +use crate::envelope::LEDGER_BUSY; +use crate::ledger::display::Render; +use crate::mcp::prepare::LEDGER_UNAVAILABLE; +use crate::models; + +/// The command kind that records an acknowledgement. +pub(crate) const ACKNOWLEDGE_COMMAND: &str = "providers.acknowledge"; + +/// Events asked for per page when the stream is read. +const PAGE: u32 = 100; + +/// Every acknowledgement already recorded, and the version of the stream +/// that holds them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Stored { + /// The stream's version when it was read, 0 when it holds nothing. + pub(crate) version: u64, + /// Each stored `providers.acknowledged` payload, oldest first. + pub(crate) payloads: Vec, +} + +fn user() -> ProjectId { + ProjectId(USER_PROJECT.into()) +} + +/// Reads the acknowledgements stored in `user`. The project list is read +/// first, so a ledger without `user` gives none and creates nothing. +pub(crate) fn read(store: &(impl Admin + Ledger)) -> Result { + let mut stored = Stored { + version: 0, + payloads: Vec::new(), + }; + if !store.projects()?.iter().any(|(known, _)| *known == user()) { + return Ok(stored); + } + let stream = StreamName(PROVIDERS_STREAM.into()); + let mut after = None; + loop { + let page = store.stream(&user(), &stream, 1, PageRequest { limit: PAGE, after })?; + for event in page.items { + stored.version = stored.version.max(event.stream_version); + if event.type_name == PROVIDERS_ACKNOWLEDGED + && event.type_version == PROVIDERS_ACKNOWLEDGED_VERSION + { + stored.payloads.push(event.payload); + } + } + match page.next { + Some(cursor) => after = Some(cursor), + None => return Ok(stored), + } + } +} + +/// The providers one stored payload covers at the compiled warning: none +/// unless it carries the current version and digest and lists its providers +/// as text. Its owner and time are history and decide nothing. +fn covered_by<'a>(payload: &'a Value, digest: &str) -> Vec<&'a str> { + let (Some(version), Some(sha), Some(listed)) = ( + payload["warning_version"].as_u64(), + payload["warning_sha256"].as_str(), + payload["providers"].as_array(), + ) else { + return Vec::new(); + }; + if version != u64::from(WARNING_VERSION) || sha != digest { + return Vec::new(); + } + listed + .iter() + .map(Value::as_str) + .collect::>>() + .unwrap_or_default() +} + +/// Judges whether a stored acknowledgement covers every one of `providers` +/// at the compiled warning. The version and the digest must both match, so a +/// reworded warning asks again whether or not its version was raised. Several +/// payloads together may cover the providers. When they do not, the one event +/// to record, given back, lists every confirmed provider; none means a stored +/// acknowledgement already covers them. +pub(crate) fn judge( + stored: &[Value], + providers: &[Provider], + owner: &str, + at: &str, +) -> Option { + let digest = warning_sha256(); + let covered: BTreeSet<&str> = stored + .iter() + .flat_map(|payload| covered_by(payload, &digest)) + .collect(); + if providers + .iter() + .all(|provider| covered.contains(provider.name())) + { + None + } else { + Some(acknowledged_event(providers, owner, at)) + } +} + +/// Appends `event` to the `providers` stream of `user`, bound to the stream +/// version `seen`, so an acknowledgement recorded since the read is refused as +/// stale instead of duplicated. Creates `user` when it is absent. +pub(crate) fn append( + store: &(impl Admin + Ledger), + event: NewEvent, + seen: u64, + request_id: RequestId, + at: &str, +) -> Result<(), StoreError> { + let actor = Actor::Owner; + let digest = request_digest(&json!({ + "kind": ACKNOWLEDGE_COMMAND, + "project": USER_PROJECT, + "actor": actor.as_str(), + "policy_version": 0, + "payload": event.payload, + })) + .map_err(|error| StoreError::Refused(Refusal::InvalidEvent(error.to_string())))?; + models::create_user(store, at)?; + let command = Command { + project: user(), + kind: CommandKind(ACKNOWLEDGE_COMMAND.into()), + request_id, + digest, + scope: vec![], + policy_version: 0, + recorded_at: at.into(), + actor, + caller: None, + }; + store.transact(&command, &mut |tx| { + tx.expect(&event.stream, seen)?; + tx.append(event.clone())?; + Ok(Decision { + kind: OutcomeKind::Done, + answer: json!({"recorded": true}), + sensitive: false, + observed: Observed::default(), + git: None, + }) + })?; + Ok(()) +} + +/// The owner's line for a failed read or append: `ledger-busy` for a busy +/// store and `ledger-unavailable` for every other failure, then the store's +/// own text. It is a write failure, exit 3. +pub(crate) fn failure(error: &StoreError) -> Render { + let code = match error { + StoreError::Busy => LEDGER_BUSY, + _ => LEDGER_UNAVAILABLE, + }; + Render { + lines: vec![format!("{code}: {error}")], + code: 3, + error: true, + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use baley_store::{Actor, StaleInput}; + use baley_store_sqlite::{Options, SqliteStore}; + use serde_json::json; + + use super::*; + use crate::ledger::open::{self, tests::Fixed}; + use crate::providers::fixtures::{DESIGN_WARNING, sha256_hex}; + + const AT: &str = "2026-10-10T09:00:00Z"; + + fn store(dir: &tempfile::TempDir) -> SqliteStore { + open::store( + &dir.path().join("home"), + AT, + Options { + timing: Arc::new(Fixed), + ..open::options() + }, + ) + .unwrap() + } + + fn request(n: u8) -> RequestId { + RequestId(format!("00000000-0000-4000-8000-{n:012x}")) + } + + fn stored_payload(providers: &[&str], version: u64, digest: &str) -> Value { + json!({ + "providers": providers, + "warning_version": version, + "warning_sha256": digest, + "owner": "Ada Owner", + "acknowledged_at": "2026-10-01T09:00:00Z", + }) + } + + fn only_event(need: Option) -> NewEvent { + need.expect("an acknowledgement was reused") + } + + #[test] + fn an_acknowledgement_of_an_older_warning_reused_is_caught() { + let current = sha256_hex(DESIGN_WARNING); + let expected = json!({ + "providers": ["openai"], + "warning_version": 1, + "warning_sha256": current, + "owner": "Ada Owner", + "acknowledged_at": AT, + }); + for stored in [ + stored_payload(&["openai"], 0, &sha256_hex("other text")), + stored_payload(&["openai"], 0, ¤t), + ] { + let need = judge(&[stored], &[Provider::OpenAi], "Ada Owner", AT); + assert_eq!(only_event(need).payload, expected); + } + } + + #[test] + fn an_acknowledgement_missing_its_warning_owner_or_time_or_off_the_providers_stream_is_caught() + { + let dir = tempfile::tempdir().unwrap(); + let ledger = store(&dir); + let seen = read(&ledger).unwrap(); + let event = only_event(judge(&seen.payloads, &[Provider::OpenAi], "Ada Owner", AT)); + append(&ledger, event, seen.version, request(1), AT).unwrap(); + + let user = ProjectId("user".into()); + assert!( + ledger + .projects() + .unwrap() + .iter() + .any(|(known, _)| *known == user) + ); + let page = |stream: &str| { + ledger + .stream( + &user, + &StreamName(stream.into()), + 1, + PageRequest { + limit: 100, + after: None, + }, + ) + .unwrap() + }; + let providers = page("providers"); + assert_eq!(providers.items.len(), 1); + let recorded = &providers.items[0]; + assert_eq!(recorded.type_name, "providers.acknowledged"); + assert_eq!(recorded.type_version, 1); + assert_eq!(recorded.actor, Actor::Owner); + assert_eq!(recorded.policy_version, 0); + assert_eq!(recorded.caller, None); + assert_eq!(recorded.recorded_at, AT); + assert_eq!( + recorded.payload, + json!({ + "providers": ["openai"], + "warning_version": 1, + "warning_sha256": sha256_hex(DESIGN_WARNING), + "owner": "Ada Owner", + "acknowledged_at": AT, + }) + ); + assert!(page("install").items.is_empty()); + } + + #[test] + fn a_current_acknowledgement_recorded_again_is_caught() { + let current = sha256_hex(DESIGN_WARNING); + let openai = stored_payload(&["openai"], 1, ¤t); + let deepseek = stored_payload(&["deepseek"], 1, ¤t); + + let alone = judge( + std::slice::from_ref(&openai), + &[Provider::OpenAi], + "Ada Owner", + AT, + ); + assert_eq!(alone, None); + + let widened = judge( + std::slice::from_ref(&openai), + &[Provider::OpenAi, Provider::DeepSeek], + "Ada Owner", + AT, + ); + assert_eq!( + only_event(widened).payload["providers"], + json!(["openai", "deepseek"]) + ); + + let together = judge( + &[openai, deepseek], + &[Provider::OpenAi, Provider::DeepSeek], + "Ada Owner", + AT, + ); + assert_eq!(together, None); + } + + #[test] + fn an_acknowledgement_of_changed_warning_text_reused_is_caught() { + let stored = stored_payload(&["openai"], 1, &sha256_hex("other text")); + let need = judge(&[stored], &[Provider::OpenAi], "Ada Owner", AT); + assert_eq!( + only_event(need).payload, + json!({ + "providers": ["openai"], + "warning_version": 1, + "warning_sha256": sha256_hex(DESIGN_WARNING), + "owner": "Ada Owner", + "acknowledged_at": AT, + }) + ); + } + + #[test] + fn a_store_failure_reported_under_the_wrong_ledger_code_is_caught() { + let stale = StoreError::Stale(StaleInput::StreamVersion { + stream: StreamName("providers".into()), + expected: 0, + actual: 1, + }); + for (error, code, text) in [ + (StoreError::Busy, "ledger-busy: ", None), + ( + StoreError::Unavailable("disk I/O error".into()), + "ledger-unavailable: ", + Some("disk I/O error"), + ), + (stale, "ledger-unavailable: ", None), + ] { + let render = failure(&error); + assert_eq!(render.lines.len(), 1, "{error}"); + assert!(render.lines[0].starts_with(code), "{}", render.lines[0]); + if let Some(text) = text { + assert!(render.lines[0].ends_with(text), "{}", render.lines[0]); + } + assert_eq!(render.code, 3, "{error}"); + assert!(render.error, "{error}"); + } + } + + #[test] + fn an_acknowledgement_read_creating_user_or_failing_on_a_fresh_ledger_is_caught() { + let dir = tempfile::tempdir().unwrap(); + let ledger = store(&dir); + let seen = read(&ledger).unwrap(); + assert_eq!(seen.version, 0); + assert!(seen.payloads.is_empty()); + assert!( + !ledger + .projects() + .unwrap() + .iter() + .any(|(known, _)| known.0 == "user") + ); + } + + #[test] + fn an_acknowledgement_appended_over_one_recorded_since_the_read_is_caught() { + let dir = tempfile::tempdir().unwrap(); + let ledger = store(&dir); + let event = |provider| acknowledged_event(&[provider], "Ada Owner", AT); + let held = |ledger: &SqliteStore| read(ledger).unwrap().payloads.len(); + + assert_eq!(read(&ledger).unwrap().version, 0); + append(&ledger, event(Provider::OpenAi), 0, request(1), AT).unwrap(); + let stale = append(&ledger, event(Provider::DeepSeek), 0, request(2), AT).unwrap_err(); + assert!( + matches!( + stale, + StoreError::Stale(StaleInput::StreamVersion { + expected: 0, + actual: 1, + .. + }) + ), + "{stale:?}" + ); + assert_eq!(held(&ledger), 1); + + assert_eq!(read(&ledger).unwrap().version, 1); + append(&ledger, event(Provider::DeepSeek), 1, request(3), AT).unwrap(); + assert_eq!(held(&ledger), 2); + let again = append(&ledger, event(Provider::OpenAi), 1, request(4), AT).unwrap_err(); + assert!(matches!(again, StoreError::Stale(_)), "{again:?}"); + assert_eq!(held(&ledger), 2); + } +} From 52cccd594a43b302d0c5f85c12f3ec2d4b355337 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 18:14:02 -0400 Subject: [PATCH 05/27] feat(config): ask for outside providers and take a typed yes to each warning The provider question follows the thirteen settings questions and shows the stored choice. Each provider chosen gets the full warning and needs yes before the summary, and a missing git user.name stops the interview there. --- crates/baley/src/config_command/interview.rs | 402 +++++++++++++++--- .../src/config_command/interview/providers.rs | 195 +++++++++ crates/baley/src/config_command/mod.rs | 10 +- crates/baley/src/config_command/reviewers.rs | 107 +++++ crates/baley/src/providers/mod.rs | 7 - 5 files changed, 643 insertions(+), 78 deletions(-) create mode 100644 crates/baley/src/config_command/interview/providers.rs create mode 100644 crates/baley/src/config_command/reviewers.rs diff --git a/crates/baley/src/config_command/interview.rs b/crates/baley/src/config_command/interview.rs index c4df5902..ee1fe339 100644 --- a/crates/baley/src/config_command/interview.rs +++ b/crates/baley/src/config_command/interview.rs @@ -1,7 +1,10 @@ //! `baley config interview` (design 0003 section 5). It gathers both settings //! files and HEAD's copy as `show` does, asks for each role's model and effort -//! and for `escalate_on_failure` in the terminal, and writes the answers -//! through one `set::apply`. It opens no store and runs no policy step itself. +//! and for `escalate_on_failure` in the terminal, then asks which outside review +//! providers to use and takes a typed `yes` to the warning for each one chosen. +//! The conversation opens no store and runs no policy step itself, and its one +//! seam is the process that reads git's `user.name`. The answers are written +//! through one `set::apply`. use baley_core::policy::config_command::{SetRefusal, judge_show}; use baley_core::policy::{ @@ -12,11 +15,17 @@ use baley_store::StoreError; use std::io::{self, BufRead, Write}; use std::path::{Path, PathBuf}; +mod providers; + +use self::providers::{Acknowledge, Refusal, Staged}; +use super::reviewers::{self, Selection}; use super::set; use crate::discovery::{self, Discovery, PROJECT_FILE}; use crate::folders::{Environment, Folders, Platform}; use crate::ledger::display::{self, Render}; use crate::policy_step::{self, Reads}; +use crate::process::Process; +use crate::providers::warning; use crate::settings; /// Asks the questions on `input` and `output`, then writes the answers. @@ -80,10 +89,22 @@ fn attempt( if let Some(note) = &start.note { writeln!(output, "{note}").map_err(failed)?; } - let outcome = - converse(&questions, &start.target, start.layer, host, input, output).map_err(failed)?; + let outcome = converse( + &questions, + &Destination { + target: &start.target, + layer: start.layer, + host, + }, + &start.selection, + &mut crate::process::System, + input, + output, + ) + .map_err(failed)?; Ok(match outcome { Outcome::Declined => Render::line("Nothing was written.", 0), + Outcome::Refused(Refusal(render)) => render, Outcome::Unchanged => { Render::line("Nothing changed: every answer kept the value in force.", 0) } @@ -124,6 +145,9 @@ struct Start { /// The pending note, set when the target is the project file and the /// working-tree file holds changes HEAD's copy does not. note: Option, + /// The outside providers the global file stores, shown before the + /// provider question. + selection: Selection, } /// Judges the reads before the first question. A refusal is exit 2 and no @@ -178,6 +202,7 @@ fn begin( .map_err(|refusal| Render::refusal(refusal.to_string()))? .map(|file| file.digest); Ok(Start { + selection: reviewers::stored(layers.global.as_ref()), policy: merge(schema, host, layers.global.as_ref(), layers.head.as_ref()), layer, target, @@ -360,48 +385,66 @@ fn read_answer(input: &mut impl BufRead) -> io::Result> { Ok(Some(line.trim().to_owned())) } -/// The one `config set` an interview sends. +/// What an interview sends once the owner has typed the final `yes`. #[derive(Debug, Clone, PartialEq, Eq)] struct Request { /// The file to write. layer: FileLayer, /// The host section to write into, `None` for the top level. host: Option, - /// The non-blank answers as name and value, in question order. + /// The non-blank answers as name and value, in question order. Empty when + /// only providers were confirmed. pairs: Vec<(String, String)>, + /// The providers to record an acknowledgement for, with the owner's name. + acknowledgement: Option, } /// What the interview does with the answers. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq)] enum Outcome { - /// The owner stopped, so nothing is written. + /// The owner stopped, so nothing is written and no store is opened. Declined, /// Every answer was blank, so there is nothing to write. Unchanged, - /// Write these answers through `config set`. + /// The owner identity is missing, so the interview stops before its summary. + Refused(Refusal), + /// Write these answers through `config set` and record this acknowledgement. Send(Request), } -fn outcome(collected: Collected, layer: FileLayer, host: Option) -> Outcome { - match collected { - Collected::Stopped => Outcome::Declined, - Collected::Answers(pairs) if pairs.is_empty() => Outcome::Unchanged, - Collected::Answers(pairs) => Outcome::Send(Request { layer, host, pairs }), - } -} - -/// The lines that say what is about to be written and where. -fn summary(target: &Path, host: Option, pairs: &[(String, String)]) -> Vec { - let section = host.map_or_else(String::new, |host| format!(" [host.{}]", host.name())); - let mut lines = vec![format!( - "The interview will write to {}{section}", - target.display() - )]; - lines.extend( - pairs +/// The lines that say what is about to be written and where. Providers come +/// first, so the settings block stays last. +fn summary( + target: &Path, + host: Option, + pairs: &[(String, String)], + acknowledgement: Option<&Acknowledge>, +) -> Vec { + let mut lines = Vec::new(); + if let Some(acknowledgement) = acknowledgement { + let names: Vec<&str> = acknowledgement + .providers .iter() - .map(|(name, value)| format!(" {name}={value}")), - ); + .map(|provider| provider.name()) + .collect(); + lines.push(format!( + "Outside review providers confirmed: {} (warning version {})", + names.join(", "), + warning::WARNING_VERSION + )); + } + if !pairs.is_empty() { + let section = host.map_or_else(String::new, |host| format!(" [host.{}]", host.name())); + lines.push(format!( + "The interview will write to {}{section}", + target.display() + )); + lines.extend( + pairs + .iter() + .map(|(name, value)| format!(" {name}={value}")), + ); + } lines } @@ -411,22 +454,16 @@ fn accepts(answer: Option<&str>) -> bool { answer == Some("yes") } -/// Lists what will be written and asks once. An interview with no answer has -/// nothing to confirm, and one that is not confirmed is stopped. +/// Lists what will be written and asks once. Whether the owner typed `yes`. fn confirm( - collected: Collected, target: &Path, host: Option, + pairs: &[(String, String)], + acknowledgement: Option<&Acknowledge>, input: &mut impl BufRead, output: &mut impl Write, -) -> io::Result { - let Collected::Answers(pairs) = collected else { - return Ok(collected); - }; - if pairs.is_empty() { - return Ok(Collected::Answers(pairs)); - } - for line in summary(target, host, &pairs) { +) -> io::Result { + for line in summary(target, host, pairs, acknowledgement) { writeln!(output, "{line}")?; } write!(output, "Write these values? Type yes to write: ")?; @@ -436,25 +473,61 @@ fn confirm( // The prompt holds the line, so end it before anything else prints. writeln!(output)?; } - Ok(if accepts(answer.as_deref()) { - Collected::Answers(pairs) - } else { - Collected::Stopped - }) + Ok(accepts(answer.as_deref())) } -/// Asks every question, confirms, and decides what to send. -fn converse( - questions: &[Question], - target: &Path, +/// Where the answers will be written, as the owner is told. +struct Destination<'a> { + /// The file's path. + target: &'a Path, + /// Which file it is. layer: FileLayer, + /// The host section, `None` for the top level. host: Option, +} + +/// Asks every question, then the provider question, confirms, and decides what +/// to send. It touches no file and no store, and its one seam is `process`. +fn converse( + questions: &[Question], + destination: &Destination<'_>, + stored: &Selection, + process: &mut dyn Process, input: &mut impl BufRead, output: &mut impl Write, ) -> io::Result { - let collected = ask(questions, input, output)?; - let collected = confirm(collected, target, host, input, output)?; - Ok(outcome(collected, layer, host)) + let Destination { + target, + layer, + host, + } = *destination; + let Collected::Answers(pairs) = ask(questions, input, output)? else { + return Ok(Outcome::Declined); + }; + let acknowledgement = match providers::stage(stored, process, input, output)? { + Staged::Declined => return Ok(Outcome::Declined), + Staged::Refused(refusal) => return Ok(Outcome::Refused(refusal)), + Staged::Ready(acknowledgement) => acknowledgement, + }; + if pairs.is_empty() && acknowledgement.is_none() { + return Ok(Outcome::Unchanged); + } + if !confirm( + target, + host, + &pairs, + acknowledgement.as_ref(), + input, + output, + )? { + return Ok(Outcome::Declined); + } + Ok(Outcome::Send(Request { + layer, + host, + pairs, + acknowledgement, + })) } #[cfg(test)] @@ -463,6 +536,7 @@ mod tests { use std::path::Path; use crate::committed::{Committed, Pending}; + use crate::process::Recorded; use baley_core::policy::{Fault, parse_layer}; const GLOBAL: &str = "/c/config.toml"; @@ -563,15 +637,30 @@ mod tests { assert!(asked.iter().all(|q| q.name != "git.remote")); } - /// Runs the conversation over supplied input lines. + /// Runs the conversation over supplied input lines, with nothing stored + /// and a process that fails the test if git is launched. fn converse_over(input: &str, host: Option) -> (Outcome, String) { + converse_with(input, host, &Selection::none(), &mut Recorded::new()) + } + + /// Runs the conversation with a stored selection and a scripted process. + fn converse_with( + input: &str, + host: Option, + stored: &Selection, + process: &mut Recorded, + ) -> (Outcome, String) { let asked = questions(&policy(None, None, None)); let mut output = Vec::new(); let outcome = converse( &asked, - Path::new(GLOBAL), - FileLayer::Global, - host, + &Destination { + target: Path::new(GLOBAL), + layer: FileLayer::Global, + host, + }, + stored, + process, &mut input.as_bytes(), &mut output, ) @@ -611,7 +700,7 @@ mod tests { #[test] fn a_blank_and_an_all_whitespace_answer_each_give_no_pair() { let (outcome, _) = converse_over( - &format!("{}yes\n", answers(&[(1, " \t "), (2, "sonnet")])), + &format!("{}\nyes\n", answers(&[(1, " \t "), (2, "sonnet")])), None, ); @@ -620,14 +709,15 @@ mod tests { #[test] fn an_answer_is_trimmed_before_it_is_sent() { - let (outcome, _) = converse_over(&format!("{}yes\n", answers(&[(0, " sonnet ")])), None); + let (outcome, _) = + converse_over(&format!("{}\nyes\n", answers(&[(0, " sonnet ")])), None); assert_eq!(pairs_of(&outcome), [("roles.planner.model", "sonnet")]); } #[test] fn an_answer_equal_to_the_value_in_force_is_still_sent() { - let (outcome, _) = converse_over(&format!("{}yes\n", answers(&[(1, "high")])), None); + let (outcome, _) = converse_over(&format!("{}\nyes\n", answers(&[(1, "high")])), None); assert_eq!(pairs_of(&outcome), [("roles.planner.effort", "high")]); } @@ -642,7 +732,7 @@ mod tests { #[test] fn thirteen_blank_answers_give_nothing_to_write_not_an_empty_request() { - let (outcome, _) = converse_over(&blanks(13), None); + let (outcome, _) = converse_over(&blanks(14), None); assert_eq!(outcome, Outcome::Unchanged); } @@ -650,7 +740,7 @@ mod tests { #[test] fn two_answers_give_one_request_in_question_order_with_the_layer_and_host() { let (outcome, output) = converse_over( - &format!("{}yes\n", answers(&[(12, "true"), (3, "max")])), + &format!("{}\nyes\n", answers(&[(12, "true"), (3, "max")])), Some(Host::ClaudeCode), ); @@ -663,6 +753,7 @@ mod tests { ("roles.analyzer.effort".to_owned(), "max".to_owned()), ("escalate_on_failure".to_owned(), "true".to_owned()), ], + acknowledgement: None, }) ); assert!( @@ -941,7 +1032,7 @@ mod tests { /// One answer, a model for the planner, then the confirmation line. fn one_answer_then(confirmation: &str) -> String { - format!("{}{confirmation}", answers(&[(0, "sonnet")])) + format!("{}\n{confirmation}", answers(&[(0, "sonnet")])) } #[test] @@ -963,7 +1054,7 @@ mod tests { #[test] fn thirteen_blank_answers_are_not_asked_to_confirm() { - let (outcome, output) = converse_over(&blanks(13), None); + let (outcome, output) = converse_over(&blanks(14), None); assert_eq!(outcome, Outcome::Unchanged); assert!(!output.contains("Write these values?"), "{output}"); @@ -984,7 +1075,7 @@ mod tests { #[test] fn the_summary_comes_before_the_question_and_names_each_pair_and_the_file() { - let input = format!("{}yes\n", answers(&[(0, "sonnet"), (1, "max")])); + let input = format!("{}\nyes\n", answers(&[(0, "sonnet"), (1, "max")])); let (_, output) = converse_over(&input, None); @@ -1042,7 +1133,7 @@ mod tests { #[test] fn a_bad_effort_is_asked_again_until_a_rung_is_given() { // The planner's effort is the second question. - let input = format!("\nHigh\nbogus\nmax\n{}yes\n", blanks(11)); + let input = format!("\nHigh\nbogus\nmax\n{}yes\n", blanks(12)); let (outcome, output) = converse_over(&input, None); @@ -1065,7 +1156,7 @@ mod tests { #[test] fn a_bad_escalate_answer_is_asked_again_and_false_is_taken() { - let input = format!("{}yes\nfalse\nyes\n", blanks(12)); + let input = format!("{}yes\nfalse\n\nyes\n", blanks(12)); let (outcome, output) = converse_over(&input, None); @@ -1075,7 +1166,7 @@ mod tests { #[test] fn a_blank_after_a_bad_answer_keeps_the_value_and_gives_no_pair() { - let input = format!("\nbogus\n\n{}", blanks(11)); + let input = format!("\nbogus\n\n{}", blanks(12)); let (outcome, _) = converse_over(&input, None); @@ -1091,7 +1182,7 @@ mod tests { #[test] fn an_unknown_model_is_sent_on_not_asked_again() { - let input = format!("claude-unknown-x\n{}yes\n", blanks(12)); + let input = format!("claude-unknown-x\n{}yes\n", blanks(13)); let (outcome, output) = converse_over(&input, None); @@ -1189,4 +1280,181 @@ mod tests { assert_eq!(start.ok().unwrap().note, None); } + + use crate::providers::fixtures::DESIGN_WARNING; + use baley_core::catalog::Provider; + + /// Thirteen blank settings answers, then the given lines. + fn after_thirteen(rest: &str) -> String { + format!("{}{rest}", blanks(13)) + } + + fn stored(providers: &[Provider], layer: &'static str) -> Selection { + Selection { + providers: providers.to_vec(), + layer, + } + } + + fn ada() -> Recorded { + Recorded::new().out("Ada Owner\n") + } + + fn acknowledgement_of(outcome: &Outcome) -> Option<&Acknowledge> { + match outcome { + Outcome::Send(request) => request.acknowledgement.as_ref(), + _ => None, + } + } + + #[test] + fn a_provider_taken_without_the_full_warning_and_a_typed_yes_is_caught() { + let mut process = ada(); + let (outcome, output) = converse_with( + &after_thirteen("deepseek, openai\nyes\nyes\nyes\n"), + None, + &stored(&[Provider::OpenAi], "global"), + &mut process, + ); + + let warned = |name: &str| { + let heading = format!("Warning for {name} (version 1):\n{DESIGN_WARNING}\n"); + let prompt = format!("Type yes to accept this warning for {name}: "); + let at = output + .find(&format!("{heading}{prompt}")) + .unwrap_or_else(|| panic!("no full warning and prompt for {name}: {output}")); + at + heading.len() + prompt.len() + }; + let after_openai = warned("openai"); + let after_deepseek = warned("deepseek"); + assert!(after_openai < after_deepseek, "{output}"); + assert_eq!(output.matches(DESIGN_WARNING).count(), 2, "{output}"); + let summary = output.find("Outside review providers confirmed").unwrap(); + let final_prompt = output + .find("Write these values? Type yes to write: ") + .unwrap(); + assert!( + after_deepseek <= summary && summary < final_prompt, + "{output}" + ); + assert_eq!( + acknowledgement_of(&outcome), + Some(&Acknowledge { + providers: vec![Provider::OpenAi, Provider::DeepSeek], + owner: "Ada Owner".into(), + }) + ); + + for refusal in ["\n", "y\n"] { + let (outcome, output) = converse_with( + &after_thirteen(&format!("openai\n{refusal}")), + None, + &Selection::none(), + &mut Recorded::new(), + ); + assert_eq!(outcome, Outcome::Declined, "{refusal:?}"); + assert!(!output.contains("Write these values?"), "{output}"); + } + } + + #[test] + fn a_write_or_store_open_after_an_interview_ended_without_yes_is_caught() { + let cases = [ + (after_thirteen("\n"), Outcome::Unchanged), + (after_thirteen("openai\nno\n"), Outcome::Declined), + (after_thirteen("openai\n"), Outcome::Declined), + (after_thirteen(""), Outcome::Declined), + (format!("\nmax\n{}\nno\n", blanks(11)), Outcome::Declined), + ]; + for (input, expected) in cases { + let mut process = Recorded::new(); + let (outcome, output) = converse_with(&input, None, &Selection::none(), &mut process); + assert_eq!(outcome, expected, "{input:?}"); + assert_eq!(acknowledgement_of(&outcome), None, "{input:?}"); + assert!(process.launches().is_empty(), "{input:?}"); + if expected == Outcome::Unchanged { + assert!(!output.contains("Write these values?"), "{output}"); + } + } + } + + #[test] + fn a_provider_acknowledged_without_a_git_user_name_is_caught() { + let input = after_thirteen("openai\nyes\nyes\n"); + for mut process in [ + Recorded::new().fail(1, ""), + Recorded::new().unavailable(std::io::ErrorKind::NotFound.into()), + ] { + let (outcome, output) = converse_with(&input, None, &Selection::none(), &mut process); + + let Outcome::Refused(Refusal(render)) = outcome else { + panic!("not refused: {outcome:?}"); + }; + assert!( + render.lines[0].starts_with("owner-identity-missing:"), + "{render:?}" + ); + assert!(render.lines[0].contains("user.name"), "{render:?}"); + assert_eq!(render.code, 2); + assert!(render.error); + assert!(!output.contains("Write these values?"), "{output}"); + assert_eq!(process.arguments(), [["config", "--get", "user.name"]]); + } + + let mut process = Recorded::new(); + converse_with( + &after_thirteen("\n"), + None, + &Selection::none(), + &mut process, + ); + assert!(process.launches().is_empty()); + } + + #[test] + fn a_bad_provider_answer_taken_or_not_asked_again_is_caught() { + let mut process = ada(); + let (outcome, output) = converse_with( + &after_thirteen("anthropic\nopenai\nyes\nyes\n"), + None, + &Selection::none(), + &mut process, + ); + + let prompt = "outside providers are none from default: "; + assert_eq!(output.matches(prompt).count(), 2, "{output}"); + let first = output.find(prompt).unwrap(); + let refusal = output.find("\"anthropic\" is not an answer").unwrap(); + let second = output.rfind(prompt).unwrap(); + assert!(first < refusal && refusal < second, "{output}"); + assert_eq!( + acknowledgement_of(&outcome), + Some(&Acknowledge { + providers: vec![Provider::OpenAi], + owner: "Ada Owner".into(), + }) + ); + } + + #[test] + fn the_stored_selection_left_out_of_the_provider_question_is_caught() { + for (stored, prompt) in [ + ( + stored(&[Provider::DeepSeek], "global"), + "outside providers are deepseek from global: ", + ), + ( + stored(&[Provider::OpenAi, Provider::DeepSeek], "global"), + "outside providers are openai, deepseek from global: ", + ), + ] { + let mut process = Recorded::new(); + let (outcome, output) = + converse_with(&after_thirteen("\n"), None, &stored, &mut process); + + assert!(output.contains(prompt), "{output}"); + assert_eq!(outcome, Outcome::Unchanged); + assert!(process.launches().is_empty()); + } + } } diff --git a/crates/baley/src/config_command/interview/providers.rs b/crates/baley/src/config_command/interview/providers.rs new file mode 100644 index 00000000..a6c55374 --- /dev/null +++ b/crates/baley/src/config_command/interview/providers.rs @@ -0,0 +1,195 @@ +//! The provider stage of the interview (design 0003 section 5, Provider +//! selection and Provider confirmation). It is pure over the input and output +//! it is given, and its one seam is the process that reads git's `user.name`. + +use std::io::{self, BufRead, Write}; + +use baley_core::catalog::Provider; + +use super::super::reviewers::Selection; +use super::{accepts, read_answer}; +use crate::ledger::display::Render; +use crate::process::Process; +use crate::providers::identity; +use crate::providers::warning::{WARNING_TEXT, WARNING_VERSION}; + +/// The providers the owner explicitly confirmed and the name to record them +/// under. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct Acknowledge { + /// The confirmed providers, each once, in catalog order. + pub(super) providers: Vec, + /// git's `user.name`. + pub(super) owner: String, +} + +/// A refusal before the summary, compared by what it prints and how it exits. +#[derive(Debug)] +pub(super) struct Refusal(pub(super) Render); + +impl PartialEq for Refusal { + fn eq(&self, other: &Refusal) -> bool { + (&self.0.lines, self.0.code, self.0.error) == (&other.0.lines, other.0.code, other.0.error) + } +} +impl Eq for Refusal {} + +/// How the provider stage ended. +#[derive(Debug, PartialEq, Eq)] +pub(super) enum Staged { + /// A warning was not accepted or the input ended, so the interview stops. + Declined, + /// The owner identity is missing, so the interview stops before its summary. + Refused(Refusal), + /// Every warning shown was accepted. The request is `None` when no + /// provider was chosen explicitly. + Ready(Option), +} + +/// What the owner answered to the provider question. +#[derive(Debug, PartialEq, Eq)] +pub(super) enum Answer { + /// Blank: keep the stored selection and request no acknowledgement. + Keep, + /// An explicit choice: each provider once, in catalog order. Empty is the + /// explicit choice of none. + Choose(Vec), +} + +/// Reads an answer to the provider question. Names are `openai` and +/// `deepseek`, separated by commas, spaces or both, and `none` stands alone. +/// Case is not folded, so `OpenAI` is not a name, and `host` is the host's own +/// reviewer, not an outside provider. A refusal is the line that asks again. +pub(super) fn parse_answer(text: &str) -> Result { + let text = text.trim(); + if text.is_empty() { + return Ok(Answer::Keep); + } + if text == "none" { + return Ok(Answer::Choose(Vec::new())); + } + let mut named = Vec::new(); + for word in text + .split(|c: char| c == ',' || c.is_whitespace()) + .filter(|word| !word.is_empty()) + { + match Provider::parse(word) { + Some(provider) => named.push(provider), + None => return Err(refusal(text)), + } + } + if named.is_empty() { + return Err(refusal(text)); + } + Ok(Answer::Choose( + Provider::ALL + .into_iter() + .filter(|provider| named.contains(provider)) + .collect(), + )) +} + +fn refusal(text: &str) -> String { + format!( + "\"{}\" is not an answer; answer openai, deepseek, both separated by a comma, none, or press Enter to keep the stored choice", + text.escape_debug() + ) +} + +/// Asks the provider question, shows each chosen provider's warning and takes +/// its typed `yes`, then reads the owner identity. +/// +/// Only an explicit choice of a provider asks for its warning, and it does so +/// even when the stored selection already holds that provider. Any answer but +/// `yes`, a blank or ended input declines the whole interview without asking +/// again. +pub(super) fn stage( + stored: &Selection, + process: &mut dyn Process, + input: &mut impl BufRead, + output: &mut impl Write, +) -> io::Result { + writeln!( + output, + "The next question chooses the outside review providers: answer openai, deepseek, both separated by a comma, none for no outside provider, or press Enter to keep the stored choice." + )?; + let chosen = loop { + write!( + output, + "outside providers are {} from {}: ", + stored.text(), + stored.layer + )?; + output.flush()?; + let Some(answer) = read_answer(input)? else { + // The prompt holds the line, so end it before anything else prints. + writeln!(output)?; + return Ok(Staged::Declined); + }; + match parse_answer(&answer) { + Ok(Answer::Keep) => return Ok(Staged::Ready(None)), + Ok(Answer::Choose(chosen)) => break chosen, + Err(line) => writeln!(output, "{line}")?, + } + }; + if chosen.is_empty() { + return Ok(Staged::Ready(None)); + } + for provider in &chosen { + let name = provider.name(); + writeln!(output, "Warning for {name} (version {WARNING_VERSION}):")?; + writeln!(output, "{WARNING_TEXT}")?; + write!(output, "Type yes to accept this warning for {name}: ")?; + output.flush()?; + let answer = read_answer(input)?; + if answer.is_none() { + writeln!(output)?; + } + if !accepts(answer.as_deref()) { + return Ok(Staged::Declined); + } + } + Ok(match identity::read(process) { + Ok(owner) => Staged::Ready(Some(Acknowledge { + providers: chosen, + owner, + })), + Err(render) => Staged::Refused(Refusal(render)), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_provider_answer_read_loosely_or_a_repeat_counted_twice_is_caught() { + let open = Answer::Choose(vec![Provider::OpenAi]); + let both = Answer::Choose(vec![Provider::OpenAi, Provider::DeepSeek]); + for (text, expected) in [ + ("openai", open), + ("deepseek, openai", both), + ( + "openai,deepseek", + Answer::Choose(vec![Provider::OpenAi, Provider::DeepSeek]), + ), + ("openai openai", Answer::Choose(vec![Provider::OpenAi])), + ("none", Answer::Choose(Vec::new())), + ("", Answer::Keep), + ] { + assert_eq!(parse_answer(text), Ok(expected), "{text:?}"); + } + for text in [ + "OpenAI", + "host", + "none, openai", + "anthropic", + "openai;deepseek", + ] { + let line = parse_answer(text).expect_err(text); + for word in ["openai", "deepseek", "none"] { + assert!(line.contains(word), "{text:?}: {line}"); + } + } + } +} diff --git a/crates/baley/src/config_command/mod.rs b/crates/baley/src/config_command/mod.rs index 59c4f590..7a4857bd 100644 --- a/crates/baley/src/config_command/mod.rs +++ b/crates/baley/src/config_command/mod.rs @@ -1,13 +1,15 @@ //! `baley config`: the owner's settings commands (design 0003 section 5). //! `set` changes a settings file, `show` reports every setting and `interview` -//! asks for the model and effort of each role in the terminal. The judges -//! live in `baley_core::policy::config_command`; the files here gather what -//! they judge and print what they return. +//! asks for the model and effort of each role and for the outside review +//! providers in the terminal. The judges live in +//! `baley_core::policy::config_command`; the files here gather what they +//! judge and print what they return. //! //! The module is not named `config`, which the inherited engine holds in //! `lib.rs` and `main.rs` until Build 9. mod interview; +mod reviewers; mod set; mod show; @@ -31,7 +33,7 @@ enum ConfigCommand { Set(SetArgs), /// Show every setting with its layer, its diagnostics and where it was set. Show(ShowArgs), - /// Ask for each role's model and effort and for `escalate_on_failure`, then write the answers. + /// Ask for each role's model and effort, for `escalate_on_failure` and for the outside review providers, then write the answers. Interview(InterviewArgs), } diff --git a/crates/baley/src/config_command/reviewers.rs b/crates/baley/src/config_command/reviewers.rs new file mode 100644 index 00000000..ee4b77f0 --- /dev/null +++ b/crates/baley/src/config_command/reviewers.rs @@ -0,0 +1,107 @@ +//! The outside providers the owner has stored: the global file's top-level +//! `review.reviewers` (design 0003 section 5, Provider selection). Pure over +//! the parsed layer; the interview shows the result before it asks. + +use baley_core::catalog::Provider; +use baley_core::policy::{ParsedLayer, Value}; + +/// The setting the stored selection is read from. +const SETTING: &str = "review.reviewers"; + +/// The outside providers in force and the layer they came from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct Selection { + /// The outside providers, each once, in catalog order. + pub(super) providers: Vec, + /// `global` when the global file writes the setting, else `default`. + pub(super) layer: &'static str, +} + +impl Selection { + /// No outside provider, from the built-in default. + pub(super) fn none() -> Selection { + Selection { + providers: Vec::new(), + layer: "default", + } + } + + /// The providers as the owner reads them: names joined with commas, or `none`. + pub(super) fn text(&self) -> String { + if self.providers.is_empty() { + return "none".to_owned(); + } + let names: Vec<&str> = self + .providers + .iter() + .map(|provider| provider.name()) + .collect(); + names.join(", ") + } +} + +/// The selection the global file stores. Only the file's top level counts: a +/// host section's value belongs to that host, and `host` is the host's own +/// reviewer, not an outside provider. With no value written the selection is +/// none from `default`. +pub(super) fn stored(global: Option<&ParsedLayer>) -> Selection { + let written = global.and_then(|layer| { + layer + .values + .iter() + .find(|written| written.host.is_none() && written.name == SETTING) + }); + let Some(Value::ReviewerList(names)) = written.map(|written| &written.value) else { + return Selection::none(); + }; + Selection { + providers: Provider::ALL + .into_iter() + .filter(|provider| names.iter().any(|name| name == provider.name())) + .collect(), + layer: "global", + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use baley_core::policy::{FileLayer, Schema, parse_layer}; + + use super::*; + use crate::settings; + + fn selection_of(text: Option<&str>) -> Selection { + let layer = text.map(|text| { + let file = settings::file(Path::new("/c/config.toml"), text.as_bytes().to_vec()); + parse_layer(&file, FileLayer::Global, Schema::standard()).unwrap() + }); + stored(layer.as_ref()) + } + + #[test] + fn a_stored_selection_read_from_a_host_section_or_with_the_host_reviewer_is_caught() { + assert_eq!( + selection_of(Some("[review]\nreviewers = [\"host\", \"deepseek\"]\n")), + Selection { + providers: vec![Provider::DeepSeek], + layer: "global" + } + ); + assert_eq!( + selection_of(Some( + "[host.claude-code.review]\nreviewers = [\"openai\"]\n" + )), + Selection::none() + ); + assert_eq!( + selection_of(Some("[review]\nreviewers = [\"host\"]\n")), + Selection { + providers: Vec::new(), + layer: "global" + } + ); + assert_eq!(selection_of(None), Selection::none()); + } +} diff --git a/crates/baley/src/providers/mod.rs b/crates/baley/src/providers/mod.rs index b534adbb..8715e9bb 100644 --- a/crates/baley/src/providers/mod.rs +++ b/crates/baley/src/providers/mod.rs @@ -9,13 +9,6 @@ /// The `providers.acknowledged` event and its payload. pub mod event; /// The owner identity from git. -#[cfg_attr( - not(test), - expect( - dead_code, - reason = "the interview reads the owner identity and is wired in next" - ) -)] pub(crate) mod identity; /// Recording the acknowledgement unless a current one covers it. #[cfg_attr( From 7906b2f72e5fef63345dda7170bef4b940a75442 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 18:22:25 -0400 Subject: [PATCH 06/27] feat(config): record the acknowledgement before the settings write After the final yes the interview opens the per-user ledger, records the acknowledgement unless a current one covers it, and only then writes the settings. A ledger failure stops there with exit 3 and writes nothing else. --- crates/baley/src/config_command/interview.rs | 83 +++++++++++++++++--- crates/baley/src/providers/mod.rs | 7 -- 2 files changed, 71 insertions(+), 19 deletions(-) diff --git a/crates/baley/src/config_command/interview.rs b/crates/baley/src/config_command/interview.rs index ee1fe339..1c8bdf9f 100644 --- a/crates/baley/src/config_command/interview.rs +++ b/crates/baley/src/config_command/interview.rs @@ -3,8 +3,9 @@ //! and for `escalate_on_failure` in the terminal, then asks which outside review //! providers to use and takes a typed `yes` to the warning for each one chosen. //! The conversation opens no store and runs no policy step itself, and its one -//! seam is the process that reads git's `user.name`. The answers are written -//! through one `set::apply`. +//! seam is the process that reads git's `user.name`. Only after the final `yes` +//! does the command open the per-user ledger to record the acknowledgement, and +//! then write the answers through one `set::apply`. use baley_core::policy::config_command::{SetRefusal, judge_show}; use baley_core::policy::{ @@ -22,10 +23,13 @@ use super::reviewers::{self, Selection}; use super::set; use crate::discovery::{self, Discovery, PROJECT_FILE}; use crate::folders::{Environment, Folders, Platform}; +use crate::ledger::clock::SystemClock; +use crate::ledger::commands::new_request_id; use crate::ledger::display::{self, Render}; +use crate::ledger::open; use crate::policy_step::{self, Reads}; use crate::process::Process; -use crate::providers::warning; +use crate::providers::{record, warning}; use crate::settings; /// Asks the questions on `input` and `output`, then writes the answers. @@ -108,18 +112,73 @@ fn attempt( Outcome::Unchanged => { Render::line("Nothing changed: every answer kept the value in force.", 0) } - Outcome::Send(request) => set::apply( - request.layer, - request.host, - &request.pairs, - Some(&set::Expected { - path: start.target, - digest: start.digest, - }), - ), + Outcome::Send(request) => { + let recorded = match &request.acknowledgement { + Some(acknowledgement) => Some(acknowledge(&folders.home, acknowledgement)?), + None => None, + }; + let mut render = if request.pairs.is_empty() { + Render::line("", 0) + } else { + set::apply( + request.layer, + request.host, + &request.pairs, + Some(&set::Expected { + path: start.target, + digest: start.digest, + }), + ) + }; + // The recorded line leads, so a refused write still shows what was done. + match recorded { + Some(line) if request.pairs.is_empty() => render.lines = vec![line], + Some(line) => render.lines.insert(0, line), + None => {} + } + render + } }) } +/// Records the acknowledgement in the per-user ledger unless a current one +/// covers it, and says which. A store failure is the ledger step's own line +/// with exit 3, and nothing else is written after it. +fn acknowledge(home: &Path, acknowledgement: &Acknowledge) -> Result { + let stopped = |error: StoreError| { + let mut render = record::failure(&error); + render.lines.push("Nothing else was written.".to_owned()); + render + }; + let at = SystemClock::now(); + let store = open::store(home, &at, open::options()).map_err(stopped)?; + let seen = record::read(&store).map_err(stopped)?; + let names: Vec<&str> = acknowledgement + .providers + .iter() + .map(|provider| provider.name()) + .collect(); + let version = warning::WARNING_VERSION; + match record::judge( + &seen.payloads, + &acknowledgement.providers, + &acknowledgement.owner, + &at, + ) { + None => Ok(format!( + "The acknowledgement of {} at warning version {version} was already recorded.", + names.join(", ") + )), + Some(event) => { + record::append(&store, event, seen.version, new_request_id(), &at).map_err(stopped)?; + Ok(format!( + "The acknowledgement of {} at warning version {version} was recorded.", + names.join(", ") + )) + } + } +} + /// What the gatherer found. struct Seen { /// The folder holding the project file, `None` outside a project. diff --git a/crates/baley/src/providers/mod.rs b/crates/baley/src/providers/mod.rs index 8715e9bb..01bc6585 100644 --- a/crates/baley/src/providers/mod.rs +++ b/crates/baley/src/providers/mod.rs @@ -11,13 +11,6 @@ pub mod event; /// The owner identity from git. pub(crate) mod identity; /// Recording the acknowledgement unless a current one covers it. -#[cfg_attr( - not(test), - expect( - dead_code, - reason = "the interview records the acknowledgement and is wired in next" - ) -)] pub(crate) mod record; /// The compiled provider warning, its version and its digest. pub mod warning; From cd6bc906001735daf56502b815897ef8cd16fef4 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 18:23:22 -0400 Subject: [PATCH 07/27] feat(config): refuse config set for the reviewer list and point to the interview The list can sit in the global file as well as the project file, so its refusal names the interview and config.toml instead of baley.toml alone. --- .../src/policy/config_command/set.rs | 24 ++++++++--- .../src/policy/config_command/tests.rs | 43 +++++++++++++++++++ 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/crates/baley-core/src/policy/config_command/set.rs b/crates/baley-core/src/policy/config_command/set.rs index 61dd6088..3b3d6193 100644 --- a/crates/baley-core/src/policy/config_command/set.rs +++ b/crates/baley-core/src/policy/config_command/set.rs @@ -90,11 +90,20 @@ impl fmt::Display for SetRefusal { SetRefusal::UnknownSetting { name } => { write!(f, "{} is not a setting Baley reads", name.escape_debug()) } - SetRefusal::ListNotSettable { name } => write!( - f, - "{} is a list, and config set does not write lists; edit the list in baley.toml", - name.escape_debug() - ), + SetRefusal::ListNotSettable { name } => { + // The reviewer list lives in the global file too, and the + // interview is how an owner chooses its providers. + let advice = if name == "review.reviewers" { + "baley config interview chooses the outside providers, and the list can be edited by hand in config.toml or baley.toml" + } else { + "edit the list in baley.toml" + }; + write!( + f, + "{} is a list, and config set does not write lists; {advice}", + name.escape_debug() + ) + } SetRefusal::WrongLayer { name, scope, layer } => { let (scope, file, flag) = match (scope, layer) { (Scope::Project, _) => ("project", "global", "--project"), @@ -201,7 +210,8 @@ fn convert(kind: Kind, text: &str) -> Option { /// the earliest check any pair fails, naming the first pair that fails it: /// `not-a-project`, `unknown-setting`, `list-not-settable`, `wrong-layer`, then /// `invalid-value`. A list is refused before its layer is judged, so both files -/// get the same answer, to edit `baley.toml`. +/// get the same answer: to edit the list by hand, and for the reviewer list to +/// run the interview. /// A host comes only from `host`, so a name written with a `host..` /// prefix is unknown. A setting given twice comes back twice, so each value /// given can still be checked against the catalog. @@ -224,7 +234,7 @@ pub fn judge_pairs( entries.push(entry); } for ((name, _), entry) in pairs.iter().zip(&entries) { - if entry.kind == Kind::BranchList { + if matches!(entry.kind, Kind::BranchList | Kind::ReviewerList) { return Err(SetRefusal::ListNotSettable { name: (*name).to_owned(), }); diff --git a/crates/baley-core/src/policy/config_command/tests.rs b/crates/baley-core/src/policy/config_command/tests.rs index d95f507a..fee20fea 100644 --- a/crates/baley-core/src/policy/config_command/tests.rs +++ b/crates/baley-core/src/policy/config_command/tests.rs @@ -1941,3 +1941,46 @@ fn a_reviewer_list_shown_without_its_kind_default_or_scope_is_caught() { "review.reviewers: kind list of host, openai or deepseek, default [\"host\"], scope both" ); } + +#[test] +fn a_reviewer_list_set_from_the_command_line_not_refused_as_a_list_is_caught() { + let cases: [(FileLayer, bool); 2] = [(FileLayer::Global, false), (FileLayer::Project, true)]; + for (layer, in_project) in cases { + for pairs in [ + vec![("review.reviewers", "openai")], + vec![("review.reviewers", "[\"host\",\"openai\"]")], + vec![ + ("escalate_on_failure", "true"), + ("review.reviewers", "openai"), + ], + ] { + let refusal = judge_pairs(Schema::standard(), layer, in_project, None, &pairs) + .expect_err("a list is never set from the command line"); + assert_eq!( + refusal, + SetRefusal::ListNotSettable { + name: "review.reviewers".into() + }, + "{layer:?} {pairs:?}" + ); + let text = refusal.to_string(); + assert!( + text.starts_with( + "list-not-settable: review.reviewers is a list, and config set does not write lists" + ), + "{text}" + ); + assert!(text.contains("baley config interview"), "{text}"); + assert!(text.contains("config.toml"), "{text}"); + assert!(!text.ends_with("edit the list in baley.toml"), "{text}"); + } + } + let branches = judge_standard(FileLayer::Project, &[("git.protected_branches", "main")]) + .expect_err("a list is never set from the command line"); + assert!( + branches + .to_string() + .ends_with("edit the list in baley.toml"), + "{branches}" + ); +} From e1aaa6b9b8f2c02093f65bdf8837b2896a8c52f4 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 18:25:11 -0400 Subject: [PATCH 08/27] test(install): pin that install keeps the owner's provider hosts and records no acknowledgement Install renders no sandbox.network and its record keeps defaults empty, so the check holds today and stands guard for the later builds that add a network input. --- crates/baley/src/install/plan.rs | 61 ++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/crates/baley/src/install/plan.rs b/crates/baley/src/install/plan.rs index ada2de3d..a5351108 100644 --- a/crates/baley/src/install/plan.rs +++ b/crates/baley/src/install/plan.rs @@ -1107,4 +1107,65 @@ mod tests { plan.settings.gaps ); } + + #[test] + fn owner_provider_hosts_dropped_or_an_acknowledgement_recorded_by_install_is_caught() { + let installed = installed(); + let mut observations = all_absent(&installed); + observations.insert( + PathBuf::from(SETTINGS), + Observation { + state: FileState::Bytes( + br#"{"model":"opus","sandbox":{"network":{"allowedDomains":["api.openai.com","example.com"]}}}"# + .to_vec(), + ), + symbolic_link: false, + }, + ); + let (folders, prerequisites) = (folders(), linux()); + + let plan = judge(&gathered_for( + &installed, + None, + &observations, + &prerequisites, + &folders, + )) + .expect("an owner's settings file with provider hosts plans"); + + let write = plan + .writes + .iter() + .find(|write| write.path == Path::new(SETTINGS)) + .expect("the settings file is written"); + let written: Value = serde_json::from_slice(&write.bytes).unwrap(); + assert_eq!(written["model"], "opus"); + assert_eq!( + written["sandbox"]["network"], + json!({"allowedDomains": ["api.openai.com", "example.com"]}) + ); + + // The record install builds for that plan holds no provider or + // acknowledgement. + let attempt = Attempt { + seen: None, + removed: false, + result: Registered::Registered, + }; + let results: Vec> = plan.writes.iter().map(|_| Ok(())).collect(); + let applied = applied(&plan, &results, &attempt); + let facts = facts( + "0.2.0", + &installed.placements, + None, + &plan.settings, + &applied, + &Ok(true), + updates(), + ); + let recorded = event::recorded_event(&facts); + assert_eq!(recorded.stream.0, "install"); + assert_eq!(recorded.type_name, "install.recorded"); + assert_eq!(recorded.payload["defaults"], json!({})); + } } From 2b28ee3706444d70ad300d617ce3487e54508498 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 19:22:10 -0400 Subject: [PATCH 09/27] test(config): pin that each provider warning needs its own yes and install writes no acknowledgement A yes to the first warning must not cover the second, and an install record must leave the providers stream empty. Both were green under a wrong implementation. --- crates/baley/src/config_command/interview.rs | 18 ++++++++++++++---- crates/baley/src/install/record.rs | 8 +++++++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/crates/baley/src/config_command/interview.rs b/crates/baley/src/config_command/interview.rs index 1c8bdf9f..37ed685f 100644 --- a/crates/baley/src/config_command/interview.rs +++ b/crates/baley/src/config_command/interview.rs @@ -1404,15 +1404,25 @@ mod tests { }) ); - for refusal in ["\n", "y\n"] { + // Each warning needs its own yes, so a yes to the first never covers the + // second, and input ending at the second declines too. + for answers in [ + "openai\n\n", + "openai\ny\n", + "openai,deepseek\nyes\nno\n", + "openai,deepseek\nyes\n", + ] { + let mut process = Recorded::new(); let (outcome, output) = converse_with( - &after_thirteen(&format!("openai\n{refusal}")), + &after_thirteen(answers), None, &Selection::none(), - &mut Recorded::new(), + &mut process, ); - assert_eq!(outcome, Outcome::Declined, "{refusal:?}"); + assert_eq!(outcome, Outcome::Declined, "{answers:?}"); + assert_eq!(acknowledgement_of(&outcome), None, "{answers:?}"); assert!(!output.contains("Write these values?"), "{output}"); + assert!(process.launches().is_empty(), "{answers:?}"); } } diff --git a/crates/baley/src/install/record.rs b/crates/baley/src/install/record.rs index 2cfa6954..0ed77e0b 100644 --- a/crates/baley/src/install/record.rs +++ b/crates/baley/src/install/record.rs @@ -244,10 +244,14 @@ mod tests { } fn events(store: &impl Ledger) -> Vec { + stream_events(store, "install") + } + + fn stream_events(store: &impl Ledger, stream: &str) -> Vec { store .stream( &ProjectId("user".into()), - &StreamName("install".into()), + &StreamName(stream.into()), 0, PageRequest { limit: 100, @@ -281,6 +285,8 @@ mod tests { assert_eq!(event.request_id, request(1)); assert_eq!(event.payload, expected); assert_eq!(read(&store, "claude-code"), Ok(Some(expected))); + // Install never records a provider acknowledgement. + assert_eq!(stream_events(&store, "providers"), Vec::::new()); } #[test] From 9f9b71b92c418e689aef82ffa0aa754497c324be Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 19:28:51 -0400 Subject: [PATCH 10/27] feat(install): decide which provider hosts the settings file still needs Each provider's API host is compiled on the catalog, and the decision only ever adds hosts, so the owner's other settings and hosts stay as they were. --- crates/baley-core/src/catalog/mod.rs | 10 + crates/baley-core/src/catalog/tests.rs | 6 + crates/baley/src/install/hosts.rs | 251 +++++++++++++++++++++++++ crates/baley/src/install/mod.rs | 2 + 4 files changed, 269 insertions(+) create mode 100644 crates/baley/src/install/hosts.rs diff --git a/crates/baley-core/src/catalog/mod.rs b/crates/baley-core/src/catalog/mod.rs index 48371b65..57d6a790 100644 --- a/crates/baley-core/src/catalog/mod.rs +++ b/crates/baley-core/src/catalog/mod.rs @@ -81,6 +81,16 @@ impl Provider { .into_iter() .find(|provider| provider.name() == name) } + + /// The host the provider's API calls reach, which the interview adds to + /// Claude Code's sandbox allowance. It is compiled here and read from no + /// key table (design 0012 section 5). + pub fn api_host(self) -> &'static str { + match self { + Provider::OpenAi => "api.openai.com", + Provider::DeepSeek => "api.deepseek.com", + } + } } /// How capable a model is within its provider, from the hint table, best fit diff --git a/crates/baley-core/src/catalog/tests.rs b/crates/baley-core/src/catalog/tests.rs index c682ec95..3884674a 100644 --- a/crates/baley-core/src/catalog/tests.rs +++ b/crates/baley-core/src/catalog/tests.rs @@ -843,3 +843,9 @@ fn removing_a_hosts_owner_entry_is_allowed() { assert_eq!(judge_alias_removal(catalog, "o-own"), Ok(())); assert_eq!(judge_held_removal(catalog, "o-own", Some(&claude)), Ok(())); } + +#[test] +fn a_provider_api_host_other_than_its_documented_one_is_caught() { + assert_eq!(Provider::OpenAi.api_host(), "api.openai.com"); + assert_eq!(Provider::DeepSeek.api_host(), "api.deepseek.com"); +} diff --git a/crates/baley/src/install/hosts.rs b/crates/baley/src/install/hosts.rs new file mode 100644 index 00000000..1c4e49c3 --- /dev/null +++ b/crates/baley/src/install/hosts.rs @@ -0,0 +1,251 @@ +//! The settings-file part of the installation writer that the interview's host +//! step uses (design 0003 section 5, CFG-R28). It adds the chosen providers' +//! API hosts to `sandbox.network.allowedDomains` in Claude Code's settings +//! file and nothing else: no install lock, no install record, and no +//! composition of Baley's hook or sandbox block. + +use std::path::Path; + +use baley_core::catalog::Provider; +use serde_json::{Map, Value}; + +use super::settings::{self, Current}; +use crate::replace; + +/// The settings document the host step writes. +#[derive(Debug, Clone, PartialEq)] +pub struct Allowance { + /// The document with the hosts added. + pub document: Value, + /// The document pretty-printed with a trailing newline. + pub bytes: Vec, + /// SHA-256 of the bytes the document was read from, or none when the file + /// was absent. + pub read_digest: Option, +} + +/// The key that holds the list of allowed hosts. +const DOMAINS: &str = "allowedDomains"; + +/// The refusal for a settings file whose network setting cannot take hosts, +/// worded as `settings::judge` words a value Baley cannot add to. +fn not_composable(path: &Path, key: &str) -> String { + format!( + "install-settings-conflict: {} holds `{key}` as a kind of value Baley cannot add hosts to; fix it by hand, then run the interview again", + path.display() + ) +} + +/// The object a slot holds, or `key` when it holds anything else. +fn object<'a>( + slot: &'a mut Value, + key: &'static str, +) -> Result<&'a mut Map, &'static str> { + slot.as_object_mut().ok_or(key) +} + +/// Appends the wanted hosts the list does not hold, making `sandbox`, +/// `sandbox.network` and the list when they are missing. Whether it added any, +/// or the key that holds the wrong kind of value. +fn add_hosts(document: &mut Value, wanted: &[&str]) -> Result { + let empty = || Value::Object(Map::new()); + let root = object(document, "sandbox")?; + let sandbox = object(root.entry("sandbox").or_insert_with(empty), "sandbox")?; + let network = object( + sandbox.entry("network").or_insert_with(empty), + "sandbox.network", + )?; + let Value::Array(list) = network + .entry(DOMAINS) + .or_insert_with(|| Value::Array(Vec::new())) + else { + return Err("sandbox.network.allowedDomains"); + }; + let missing: Vec<&str> = wanted + .iter() + .copied() + .filter(|host| !list.iter().any(|held| held.as_str() == Some(host))) + .collect(); + list.extend(missing.iter().map(|host| Value::String((*host).to_owned()))); + Ok(!missing.is_empty()) +} + +/// Decides what to write so each chosen provider's API host is allowed. Hosts +/// are only added: every other key, value and host stays as it was, and a host +/// the file already holds is not added again. A new host goes after the +/// entries already there, in the catalog's order. `None` means nothing to +/// write. A file whose `sandbox`, `sandbox.network` or `allowedDomains` holds +/// the wrong kind of value is refused when a host is chosen, and a settings +/// path that is a symbolic link is refused only when a write is needed. +pub fn judge( + path: &Path, + current: &Current, + chosen: &[Provider], +) -> Result, String> { + let wanted: Vec<&str> = Provider::ALL + .into_iter() + .filter(|provider| chosen.contains(provider)) + .map(Provider::api_host) + .collect(); + if wanted.is_empty() { + return Ok(None); + } + let mut document = current.document.clone(); + if !add_hosts(&mut document, &wanted).map_err(|key| not_composable(path, key))? { + return Ok(None); + } + if current.symbolic_link { + let link = replace::Conflict::Link { path: path.into() }; + return Err(settings::replace_refusal(&link)); + } + let mut bytes = serde_json::to_vec_pretty(&document).expect("a JSON document serializes"); + bytes.push(b'\n'); + Ok(Some(Allowance { + document, + bytes, + read_digest: current.read_digest.clone(), + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host_doctor::placed::FileState; + use crate::install::plan::Observation; + use serde_json::json; + + const PATH: &str = "/home/o/.claude/settings.json"; + + fn read(document: &Value) -> Current { + let seen = Observation { + state: FileState::Bytes(serde_json::to_vec(document).unwrap()), + symbolic_link: false, + }; + settings::current(Path::new(PATH), &seen).unwrap() + } + + fn pretty(document: &Value) -> Vec { + let mut bytes = serde_json::to_vec_pretty(document).unwrap(); + bytes.push(b'\n'); + bytes + } + + #[test] + fn a_provider_host_added_by_dropping_an_owner_setting_or_host_is_caught() { + let before = json!({ + "model": "opus", + "permissions": {"deny": ["Read(//home/o/.ssh/**)"]}, + "sandbox": { + "enabled": true, + "network": { + "allowedDomains": ["example.com"], + "allowUnixSockets": ["/run/x.sock"], + }, + }, + }); + let current = read(&before); + + let one = judge(Path::new(PATH), ¤t, &[Provider::OpenAi]) + .unwrap() + .expect("a host to add"); + let mut expected = before.clone(); + expected["sandbox"]["network"]["allowedDomains"] = json!(["example.com", "api.openai.com"]); + assert_eq!(one.document, expected); + assert_eq!(one.bytes, pretty(&expected)); + assert_eq!(one.read_digest, current.read_digest); + assert!(one.read_digest.is_some()); + + let both = judge(Path::new(PATH), ¤t, &Provider::ALL) + .unwrap() + .expect("hosts to add"); + let mut expected = before; + expected["sandbox"]["network"]["allowedDomains"] = + json!(["example.com", "api.openai.com", "api.deepseek.com"]); + assert_eq!(both.document, expected); + } + fn linked(document: &Value) -> Current { + let seen = Observation { + state: FileState::Bytes(serde_json::to_vec(document).unwrap()), + symbolic_link: true, + }; + settings::current(Path::new(PATH), &seen).unwrap() + } + + #[test] + fn an_allowed_host_written_again_or_listed_twice_is_caught() { + let held = read(&json!({"sandbox": {"network": {"allowedDomains": ["api.openai.com"]}}})); + + assert_eq!(judge(Path::new(PATH), &held, &[Provider::OpenAi]), Ok(None)); + let both = judge(Path::new(PATH), &held, &Provider::ALL) + .unwrap() + .expect("the second host to add"); + assert_eq!( + both.document["sandbox"]["network"]["allowedDomains"], + json!(["api.openai.com", "api.deepseek.com"]) + ); + } + + #[test] + fn a_network_setting_that_cannot_take_hosts_written_anyway_is_caught() { + let cases = [ + (json!({"sandbox": "off"}), "sandbox"), + (json!({"sandbox": {"network": ["a"]}}), "sandbox.network"), + ( + json!({"sandbox": {"network": {"allowedDomains": {"a": 1}}}}), + "sandbox.network.allowedDomains", + ), + (json!({"sandbox": null}), "sandbox"), + (json!({"sandbox": {"network": null}}), "sandbox.network"), + ( + json!({"sandbox": {"network": {"allowedDomains": null}}}), + "sandbox.network.allowedDomains", + ), + ]; + for (document, key) in cases { + let line = judge(Path::new(PATH), &read(&document), &[Provider::OpenAi]) + .expect_err(&document.to_string()); + + assert!(line.starts_with("install-settings-conflict: "), "{line}"); + assert!(line.contains(PATH), "{line}"); + assert!(line.contains(&format!("`{key}`")), "{line}"); + } + } + + #[test] + fn a_linked_settings_file_written_through_for_hosts_is_caught() { + let refused = judge( + Path::new(PATH), + &linked(&json!({"model": "opus"})), + &[Provider::OpenAi], + ); + assert_eq!( + refused, + Err("install-settings-conflict: /home/o/.claude/settings.json is a symbolic link, which Baley does not write through (fix: edit the file it points to by hand, or replace the link with a regular file)".to_owned()) + ); + + let held = linked(&json!({"sandbox": {"network": {"allowedDomains": ["api.openai.com"]}}})); + assert_eq!(judge(Path::new(PATH), &held, &[Provider::OpenAi]), Ok(None)); + } + + #[test] + fn an_absent_settings_file_given_more_than_the_hosts_is_caught() { + let absent = settings::current( + Path::new(PATH), + &Observation { + state: FileState::Absent, + symbolic_link: false, + }, + ) + .unwrap(); + + let decided = judge(Path::new(PATH), &absent, &[Provider::OpenAi]) + .unwrap() + .expect("a first allowance"); + + assert_eq!( + decided.document, + json!({"sandbox": {"network": {"allowedDomains": ["api.openai.com"]}}}) + ); + assert_eq!(decided.read_digest, None); + } +} diff --git a/crates/baley/src/install/mod.rs b/crates/baley/src/install/mod.rs index f92e3ad1..1f47a838 100644 --- a/crates/baley/src/install/mod.rs +++ b/crates/baley/src/install/mod.rs @@ -14,6 +14,8 @@ pub mod apply; pub mod command; /// Installation ownership facts and the `install.recorded` event. pub mod event; +/// The settings-file part of the installation writer that the interview's host step uses. +pub mod hosts; /// Ordered artifact writes or a refusal from supplied file observations. pub mod plan; /// The install outcome and the owner's next steps, rendered from supplied facts. From 904c6c1769403bdfb07bbc63733d154b973483f4 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 19:36:42 -0400 Subject: [PATCH 11/27] feat(install): write the provider hosts through the digest-checked replace A settings file changed since it was read is refused, so a concurrent install is caught without taking its lock or touching its record. --- crates/baley/src/host_artifacts/security.rs | 9 +- crates/baley/src/install/hosts.rs | 221 +++++++++++++++++++- 2 files changed, 224 insertions(+), 6 deletions(-) diff --git a/crates/baley/src/host_artifacts/security.rs b/crates/baley/src/host_artifacts/security.rs index c4e0ff50..ab2054cd 100644 --- a/crates/baley/src/host_artifacts/security.rs +++ b/crates/baley/src/host_artifacts/security.rs @@ -8,10 +8,11 @@ //! nothing: every path is a supplied value, and a path that cannot be written //! as a rule is reported beside the content instead of rendered. //! -//! It holds no `sandbox.network` key. Allowing the owner's chosen providers' -//! API hosts is T16's: once the key ADR and the delivery ADR record the host -//! list, T16 adds it to [`propose`]'s inputs and renders it as -//! `sandbox.network.allowedDomains`. +//! It holds no `sandbox.network` key and install renders none. The owner's +//! chosen providers' API hosts are added to `sandbox.network.allowedDomains` +//! by the interview's host step (`install::hosts`), which keeps every other +//! key and host in the file, and install keeps those hosts as an unrelated +//! key. //! //! Host facts the keys and spellings depend on, confirmed on 2026-10-06 //! against Claude Code's published settings reference, sandboxing and diff --git a/crates/baley/src/install/hosts.rs b/crates/baley/src/install/hosts.rs index 1c4e49c3..2f4378a4 100644 --- a/crates/baley/src/install/hosts.rs +++ b/crates/baley/src/install/hosts.rs @@ -4,13 +4,19 @@ //! file and nothing else: no install lock, no install record, and no //! composition of Baley's hook or sandbox block. -use std::path::Path; +use std::fs; +use std::path::{Path, PathBuf}; use baley_core::catalog::Provider; use serde_json::{Map, Value}; +use super::plan::Observation; use super::settings::{self, Current}; -use crate::replace; +use crate::folders::Environment; +use crate::host_artifacts::{installed, stubs}; +use crate::host_doctor::placed; +use crate::ledger::display::Render; +use crate::replace::{self, Failure}; /// The settings document the host step writes. #[derive(Debug, Clone, PartialEq)] @@ -107,9 +113,98 @@ pub fn judge( })) } +/// Claude Code's settings file as the interview read it before its questions. +#[derive(Debug, Clone, PartialEq)] +pub struct ClaudeSettings { + /// The settings file's path. + pub path: PathBuf, + /// What the read found. + pub current: Current, +} + +/// Reads Claude Code's settings file where `baley install` places it, from +/// the environment and `CLAUDE_CONFIG_DIR`. The refusal is the line that says +/// why the path or the file cannot be used. +#[expect( + dead_code, + reason = "the interview calls it once the ordered writes are wired" +)] +pub(crate) fn read() -> Result { + let manifest = stubs::manifest(&stubs::front_doors()).expect("unique compiled front doors"); + let placed_at = installed::resolve( + &Environment::read(), + std::env::var_os("CLAUDE_CONFIG_DIR"), + &manifest, + ) + .map_err(|refusal| refusal.to_string())?; + let path = placed_at.settings_file; + let seen = Observation { + state: placed::read(&path), + symbolic_link: fs::symlink_metadata(&path).is_ok_and(|meta| meta.is_symlink()), + }; + let current = settings::current(&path, &seen)?; + Ok(ClaudeSettings { path, current }) +} + +/// Writes the decided document through the digest-checked replacement, so a +/// file changed since it was read is refused. The settings file's folder is +/// made first when it is missing, with the process's default mode since it is +/// Claude Code's folder. The note is the sync failure's text when the file was +/// replaced but its folder could not be synced. +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "the interview calls it once the ordered writes are wired" + ) +)] +pub(crate) fn write(path: &Path, allowance: &Allowance) -> Result, Render> { + let folder = match path.parent() { + Some(folder) if !folder.as_os_str().is_empty() => folder, + _ => Path::new("."), + }; + let made = fs::create_dir_all(folder).map_err(|cause| Failure::Unchanged { + path: path.into(), + cause, + }); + let result = made + .and_then(|()| replace::replace(path, &allowance.bytes, allowance.read_digest.as_deref())); + written(result) +} + +/// What a write's result means to the owner: a refused replacement is +/// `install-settings-conflict` with exit 2, a file or folder that could not be +/// written is `not-writable` with exit 3, and a file replaced but not synced +/// counts as written and keeps the sync failure's text. +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "the interview reaches it through write once the ordered writes are wired" + ) +)] +pub(crate) fn written(result: Result<(), Failure>) -> Result, Render> { + match result { + Ok(()) => Ok(None), + Err(Failure::Unsynced { path, cause }) => { + Ok(Some(Failure::Unsynced { path, cause }.to_string())) + } + Err(Failure::Refused(conflict)) => { + Err(Render::refusal(settings::replace_refusal(&conflict))) + } + Err(Failure::Unchanged { path, cause }) => Err(Render { + lines: vec![format!("not-writable: {}: {cause}", path.display())], + code: 3, + error: true, + }), + } +} + #[cfg(test)] mod tests { use super::*; + use std::io; + use crate::host_doctor::placed::FileState; use crate::install::plan::Observation; use serde_json::json; @@ -248,4 +343,126 @@ mod tests { ); assert_eq!(decided.read_digest, None); } + #[test] + fn a_host_write_failure_reported_under_the_wrong_code_is_caught() { + let path = PathBuf::from(PATH); + let conflict = replace::Conflict::Changed { path: path.clone() }; + let render = written(Err(Failure::Refused(conflict))).unwrap_err(); + assert_eq!(render.code, 2); + assert!(render.error); + assert!( + render.lines[0].starts_with("install-settings-conflict: "), + "{render:?}" + ); + + let denied = io::Error::from_raw_os_error(13); + let temporary = PathBuf::from("/home/o/.claude/.settings.json.7-0.tmp"); + let render = written(Err(Failure::Unchanged { + path: temporary, + cause: denied, + })) + .unwrap_err(); + assert_eq!(render.code, 3); + assert!(render.error); + assert_eq!( + render.lines, + [ + "not-writable: /home/o/.claude/.settings.json.7-0.tmp: Permission denied (os error 13)" + ] + ); + assert!(render.lines[0].contains("Permission denied"), "{render:?}"); + + let unsynced = written(Err(Failure::Unsynced { + path: path.clone(), + cause: io::Error::from_raw_os_error(5), + })) + .unwrap(); + let note = unsynced.expect("the sync failure's text is kept"); + assert!(note.contains(PATH), "{note}"); + assert!(note.contains("Input/output error"), "{note}"); + + let folder = written(Err(Failure::Unchanged { + path, + cause: io::Error::from_raw_os_error(13), + })) + .unwrap_err(); + assert_eq!(folder.code, 3); + assert!(folder.lines[0].starts_with("not-writable: "), "{folder:?}"); + + assert_eq!(written(Ok(())).unwrap(), None); + } + + /// Reads and decides for OpenAI from the file at `path`, as the interview + /// does before its questions. + fn decided(path: &Path) -> Allowance { + let seen = Observation { + state: placed::read(path), + symbolic_link: false, + }; + let current = settings::current(path, &seen).unwrap(); + judge(path, ¤t, &[Provider::OpenAi]) + .unwrap() + .expect("a host to add") + } + + fn parsed(path: &Path) -> Value { + serde_json::from_slice(&fs::read(path).unwrap()).unwrap() + } + + #[test] + fn a_host_write_over_a_settings_file_changed_since_it_was_read_is_caught() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + fs::write(&path, r#"{"model":"opus"}"#).unwrap(); + let allowance = decided(&path); + fs::write(&path, r#"{"model":"sonnet"}"#).unwrap(); + + let refused = write(&path, &allowance).unwrap_err(); + + assert_eq!(refused.code, 2); + assert!( + refused.lines[0].starts_with("install-settings-conflict: "), + "{refused:?}" + ); + assert_eq!(parsed(&path), json!({"model": "sonnet"})); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + fs::write(&path, r#"{"model":"opus"}"#).unwrap(); + let allowance = decided(&path); + + write(&path, &allowance).unwrap(); + + assert_eq!( + parsed(&path), + json!({"model": "opus", "sandbox": {"network": {"allowedDomains": ["api.openai.com"]}}}) + ); + } + + #[test] + fn a_missing_claude_folder_left_uncreated_for_the_hosts_is_caught() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("claude").join("settings.json"); + let allowance = decided(&path); + + write(&path, &allowance).unwrap(); + + assert_eq!(parsed(&path), allowance.document); + + let dir = tempfile::tempdir().unwrap(); + let folder = dir.path().join("claude"); + let path = folder.join("settings.json"); + let allowance = decided(&path); + fs::create_dir(&folder).unwrap(); + fs::write(&path, r#"{"model":"sonnet"}"#).unwrap(); + + let refused = write(&path, &allowance).unwrap_err(); + + assert_eq!(refused.code, 2); + assert!( + refused.lines[0].starts_with("install-settings-conflict: "), + "{refused:?}" + ); + assert_eq!(parsed(&path), json!({"model": "sonnet"})); + } } From aa340e53b821132097bfa018d2ec06d7859f20fe Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 19:41:36 -0400 Subject: [PATCH 12/27] feat(config): decide the reviewer list from the stored one and the owner's choice The host reviewer stays as the file has it and only the outside providers are replaced, so a deselection never turns the host reviewer on or off. --- crates/baley/src/config_command/reviewers.rs | 116 +++++++++++++++++-- 1 file changed, 105 insertions(+), 11 deletions(-) diff --git a/crates/baley/src/config_command/reviewers.rs b/crates/baley/src/config_command/reviewers.rs index ee4b77f0..9f3a100b 100644 --- a/crates/baley/src/config_command/reviewers.rs +++ b/crates/baley/src/config_command/reviewers.rs @@ -2,12 +2,22 @@ //! `review.reviewers` (design 0003 section 5, Provider selection). Pure over //! the parsed layer; the interview shows the result before it asks. +use std::collections::BTreeSet; + use baley_core::catalog::Provider; +use baley_core::policy::config_command::TypedPair; use baley_core::policy::{ParsedLayer, Value}; /// The setting the stored selection is read from. const SETTING: &str = "review.reviewers"; +/// The host's own reviewer, which the list keeps as it finds it. +#[cfg_attr( + not(test), + expect(dead_code, reason = "only the list decision reads it") +)] +const HOST: &str = "host"; + /// The outside providers in force and the layer they came from. #[derive(Debug, Clone, PartialEq, Eq)] pub(super) struct Selection { @@ -40,18 +50,25 @@ impl Selection { } } -/// The selection the global file stores. Only the file's top level counts: a -/// host section's value belongs to that host, and `host` is the host's own -/// reviewer, not an outside provider. With no value written the selection is -/// none from `default`. +/// The names the global file's top-level `review.reviewers` lists, as written, +/// or `None` when the file writes no such value. A host section's value +/// belongs to that host and does not count. +pub(super) fn written(global: Option<&ParsedLayer>) -> Option> { + let written = global? + .values + .iter() + .find(|written| written.host.is_none() && written.name == SETTING)?; + match &written.value { + Value::ReviewerList(names) => Some(names.clone()), + _ => None, + } +} + +/// The selection the global file stores. `host` is the host's own reviewer, not +/// an outside provider. With no value written the selection is none from +/// `default`. pub(super) fn stored(global: Option<&ParsedLayer>) -> Selection { - let written = global.and_then(|layer| { - layer - .values - .iter() - .find(|written| written.host.is_none() && written.name == SETTING) - }); - let Some(Value::ReviewerList(names)) = written.map(|written| &written.value) else { + let Some(names) = written(global) else { return Selection::none(); }; Selection { @@ -63,6 +80,37 @@ pub(super) fn stored(global: Option<&ParsedLayer>) -> Selection { } } +/// The list to write so the global file names the chosen outside providers, or +/// `None` when it needs no write. `stored` is the global file's top-level +/// `review.reviewers` as written, `None` for no value, which means the default +/// `["host"]`. `chosen` is the owner's explicit choice, `None` for a blank +/// answer, which writes nothing. +#[cfg_attr( + not(test), + expect(dead_code, reason = "the interview's write plan calls it") +)] +pub(super) fn decide(stored: Option<&[String]>, chosen: Option<&[Provider]>) -> Option { + let chosen = chosen?; + let default = [HOST.to_owned()]; + let held = stored.unwrap_or(&default); + let mut list = Vec::new(); + if held.iter().any(|name| name == HOST) { + list.push(HOST.to_owned()); + } + list.extend( + Provider::ALL + .into_iter() + .filter(|provider| chosen.contains(provider)) + .map(|provider| provider.name().to_owned()), + ); + let same = held.iter().collect::>() == list.iter().collect::>(); + (!same).then(|| TypedPair { + name: SETTING.to_owned(), + host: None, + value: Value::ReviewerList(list), + }) +} + #[cfg(test)] mod tests { use std::path::Path; @@ -104,4 +152,50 @@ mod tests { ); assert_eq!(selection_of(None), Selection::none()); } + fn names(names: &[&str]) -> Vec { + names.iter().map(|name| (*name).to_owned()).collect() + } + + /// The list `decide` writes, as names. + fn written_by(stored: Option<&[&str]>, chosen: &[Provider]) -> Option> { + let stored = stored.map(names); + let pair = decide(stored.as_deref(), Some(chosen))?; + assert_eq!(pair.name, "review.reviewers"); + assert_eq!(pair.host, None); + let Value::ReviewerList(list) = pair.value else { + panic!("not a reviewer list: {:?}", pair.value); + }; + Some(list) + } + + #[test] + fn a_reviewer_list_that_drops_the_host_reviewer_or_keeps_a_deselected_provider_is_caught() { + use Provider::{DeepSeek, OpenAi}; + assert_eq!( + written_by(None, &[OpenAi]), + Some(names(&["host", "openai"])) + ); + assert_eq!( + written_by(Some(&["openai"]), &[DeepSeek]), + Some(names(&["deepseek"])) + ); + assert_eq!( + written_by(Some(&["host", "deepseek"]), &[]), + Some(names(&["host"])) + ); + assert_eq!( + written_by(Some(&["deepseek", "host"]), &[OpenAi, DeepSeek]), + Some(names(&["host", "openai", "deepseek"])) + ); + assert_eq!(written_by(Some(&[]), &[OpenAi]), Some(names(&["openai"]))); + } + #[test] + fn a_reviewer_list_rewritten_when_it_already_names_the_selection_is_caught() { + use Provider::OpenAi; + let both = names(&["openai", "host"]); + assert_eq!(decide(Some(&both), Some(&[OpenAi])), None); + assert_eq!(decide(None, Some(&[])), None); + assert_eq!(decide(Some(&names(&["host"])), Some(&[])), None); + assert_eq!(decide(Some(&names(&["host", "openai"])), None), None); + } } From 5845928165a16f5674a8e2611aa0293097fe846b Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 19:46:25 -0400 Subject: [PATCH 13/27] feat(config): write the reviewer list through config set's own checks The global file is judged, bound to the digest the owner was shown, refused through a link and rendered from its own bytes, so no other setting is lost, and the digest of what was written goes back for the settings step to bind to. --- crates/baley/src/config_command/set.rs | 373 ++++++++++++++++++++++--- 1 file changed, 327 insertions(+), 46 deletions(-) diff --git a/crates/baley/src/config_command/set.rs b/crates/baley/src/config_command/set.rs index c1fa1cd7..db3be012 100644 --- a/crates/baley/src/config_command/set.rs +++ b/crates/baley/src/config_command/set.rs @@ -11,7 +11,7 @@ use baley_core::catalog::{ Catalog, MODEL_CATALOG_VIEW, USER_PROJECT, accepted_names, catalog_key, state_key, }; use baley_core::policy::config_command::{ - Place, VersionSource, changed_pairs, choose_outcome, collapse_repeats, judge_models, + Place, TypedPair, VersionSource, changed_pairs, choose_outcome, collapse_repeats, judge_models, judge_pairs, needs_catalog, render_file, render_set, }; use baley_core::policy::recorded::{ @@ -23,7 +23,7 @@ use baley_core::policy::{ }; use baley_store::{Admin, Ledger, ProjectId, RequestId, StoreError, Views}; -use crate::checkout::{Site, gather_and_admit}; +use crate::checkout::{EntryError, EntryRefusal, Site, gather_and_admit}; use crate::discovery::{self, Discovery, PROJECT_FILE}; use crate::folders::{self, Environment, Folders, Platform}; use crate::ledger::clock::SystemClock; @@ -31,6 +31,7 @@ use crate::ledger::commands::new_request_id; use crate::ledger::display::{self, Render}; use crate::ledger::open; use crate::policy_step::{self, Reads}; +use crate::providers::record; use crate::{init, models, replace, settings}; /// Writes `pairs` into the file `layer` names and returns what the command @@ -62,6 +63,106 @@ fn refusal(text: impl ToString) -> Render { Render::refusal(text.to_string()) } +/// What a reviewer-list write did. +#[derive(Debug, Clone, PartialEq, Eq)] +#[expect(dead_code, reason = "the interview's ordered writes call it")] +pub(crate) struct ListWritten { + /// The SHA-256 of the bytes written, which the settings step binds to when + /// it writes the same file. + pub(crate) digest: String, + /// The policy version the step recorded, when the write ran in a project in + /// this machine's ledger. + pub(crate) version: Option, +} + +/// Writes one top-level `review.reviewers` list into the global file, which the +/// interview showed with `expected`'s digest, and runs the policy step when the +/// working directory is in a project the ledger lists. It reuses `config set`'s +/// judgements and file write, but never goes through `judge_pairs`, so +/// `list-not-settable` does not apply to it. +#[expect(dead_code, reason = "the interview's ordered writes call it")] +pub(crate) fn apply_list(pair: &TypedPair, expected: &Expected) -> Result { + let folders = Folders::resolve(Platform::current(), &Environment::read()).map_err(refusal)?; + let found = found_project()?; + let global_path = settings::global_path(&folders); + let (project, reads) = gather_reads(found, &global_path, &folders.config); + let prepared = prepare( + FileLayer::Global, + &global_path, + project.as_ref(), + &reads, + Schema::standard(), + ) + .map_err(refusal)?; + + // The store opens before the write, so an unsafe ledger refuses while the + // file is still as it was. + let at = SystemClock::now(); + let mut ledgered = None; + if let Some(project) = &prepared.project { + let opened = + open::store(&folders.home, &at, open::options()).map_err(|e| record::failure(&e))?; + if in_ledger(&opened, &project.id).map_err(|e| record::failure(&e))? { + ledgered = Some((opened, project)); + } + } + let digest = replace_list(&prepared, expected, pair, &folders.config)?; + let version = match &ledgered { + Some((store, project)) => { + // A failure from here on leaves the file written, so it says so + // after its own code line. + let step = || -> Result { + let reads = regather(&folders.config, project).map_err(refusal)?; + let (policy, recorded) = build_policy(project, &reads)?; + admit_checkout(store, project, &policy, &recorded, &at).map_err( + |error| match error.refusal() { + EntryRefusal::Store(error) => record::failure(error), + _ => crate::ledger::display::entry_error(&error, &project.id), + }, + )?; + record_version(store, project, &recorded, new_request_id(), &at) + .map_err(|error| record::failure(&error)) + }; + Some(step().map_err(|mut render| { + render.lines.push(format!( + "wrote {}, and the change stands", + prepared.target.display() + )); + render + })?) + } + None => None, + }; + Ok(ListWritten { digest, version }) +} + +/// Replaces the global file with `pair` written into it, given what the +/// interview showed. The file is refused when it is not the one shown or is a +/// link, every other key is kept, and a missing config folder is created +/// private. Returns the SHA-256 of the bytes written. It takes its paths and +/// reads as values, so a test runs it in a folder of its own. +#[cfg_attr(not(test), expect(dead_code, reason = "apply_list calls it"))] +fn replace_list( + prepared: &Prepared, + expected: &Expected, + pair: &TypedPair, + config_folder: &Path, +) -> Result { + let digest = prepared.base.as_ref().map(|file| file.digest.as_str()); + check_expected(expected, &prepared.target, digest)?; + refuse_link(&prepared.target).map_err(refusal)?; + let bytes = render_file( + Schema::standard(), + FileLayer::Global, + prepared.base.as_ref(), + std::slice::from_ref(pair), + ) + .map_err(refusal)?; + write_file(&prepared.target, &bytes, digest, Some(config_folder)) + .map_err(|failure| render_failure(&failure))?; + Ok(settings::file(&prepared.target, bytes).digest) +} + fn attempt( layer: FileLayer, host: Option, @@ -69,14 +170,7 @@ fn attempt( expected: Option<&Expected>, ) -> Result { let folders = Folders::resolve(Platform::current(), &Environment::read()).map_err(refusal)?; - let unavailable = - |error: io::Error| display::store_error(&StoreError::Unavailable(error.to_string()), None); - let cwd = std::env::current_dir().map_err(unavailable)?; - let ancestors = discovery::ancestors(&cwd).map_err(unavailable)?; - let found = match discovery::discover(&ancestors) { - Discovery::Managed { folder, root } => Some((folder, root)), - Discovery::Unmanaged { .. } | Discovery::Outside => None, - }; + let found = found_project()?; check_project_expected(layer, found.is_some(), expected)?; let schema = Schema::standard(); @@ -87,26 +181,7 @@ fn attempt( let typed = judge_pairs(schema, layer, found.is_some(), host, &named).map_err(refusal)?; let global_path = settings::global_path(&folders); - let (project, reads) = match found { - Some((folder, root)) => { - let working = settings::read(&folder.join(PROJECT_FILE)); - let head = working.as_ref().ok().and_then(Option::as_ref); - let reads = - policy_step::gather(&folders.config, &root, head, &mut crate::process::System); - ( - Some(ProjectSeen { - folder, - root, - working, - }), - reads, - ) - } - None => { - let global = settings::read(&global_path); - (None, Reads { global, head: None }) - } - }; + let (project, reads) = gather_reads(found, &global_path, &folders.config); let prepared = prepare(layer, &global_path, project.as_ref(), &reads, schema).map_err(refusal)?; if let Some(expected) = expected { @@ -177,21 +252,8 @@ fn attempt( let (policy, recorded) = build_policy(project, &reads)?; // Checkout admission runs only here, where the set runs the // policy step, so it sits inside the note's closure. - let site = Site { - root: &project.root, - policy: &policy, - path: &recorded.checkout, - }; - gather_and_admit( - store, - &ProjectId(project.id.clone()), - &site, - &mut crate::process::System, - new_request_id(), - &at, - None, - ) - .map_err(|error| crate::ledger::display::entry_error(&error, &project.id))?; + admit_checkout(store, project, &policy, &recorded, &at) + .map_err(|error| crate::ledger::display::entry_error(&error, &project.id))?; record_policy(store, project, &recorded, new_request_id(), &at) }; step().map_err(|mut render| { @@ -214,6 +276,48 @@ fn attempt( }) } +/// The repository root and the folder holding `baley.toml` for the project the +/// working directory is in, or none outside a managed project. +fn found_project() -> Result, Render> { + let unavailable = + |error: io::Error| display::store_error(&StoreError::Unavailable(error.to_string()), None); + let cwd = std::env::current_dir().map_err(unavailable)?; + let ancestors = discovery::ancestors(&cwd).map_err(unavailable)?; + Ok(match discovery::discover(&ancestors) { + Discovery::Managed { folder, root } => Some((folder, root)), + Discovery::Unmanaged { .. } | Discovery::Outside => None, + }) +} + +/// Reads the settings files a write judges: in a project the working-tree +/// project file, the global file and HEAD's copy, and outside one the global +/// file alone. +fn gather_reads( + found: Option<(PathBuf, PathBuf)>, + global_path: &Path, + config: &Path, +) -> (Option, Reads) { + match found { + Some((folder, root)) => { + let working = settings::read(&folder.join(PROJECT_FILE)); + let head = working.as_ref().ok().and_then(Option::as_ref); + let reads = policy_step::gather(config, &root, head, &mut crate::process::System); + ( + Some(ProjectSeen { + folder, + root, + working, + }), + reads, + ) + } + None => { + let global = settings::read(global_path); + (None, Reads { global, head: None }) + } + } +} + /// A vanished project after the interview began is a conflict, not a new project. fn check_project_expected( layer: FileLayer, @@ -331,6 +435,31 @@ fn build_policy( Ok((policy, recorded)) } +/// Admits the project's checkout, built at the project's root by +/// `build_policy`. It runs before the policy step, where a write runs one. +fn admit_checkout( + store: &(impl Ledger + Views), + project: &Project, + policy: &EffectivePolicy, + recorded: &RecordedPolicy, + at: &str, +) -> Result<(), EntryError> { + let site = Site { + root: &project.root, + policy, + path: &recorded.checkout, + }; + gather_and_admit( + store, + &ProjectId(project.id.clone()), + &site, + &mut crate::process::System, + new_request_id(), + at, + None, + ) +} + /// Runs the policy step for `recorded`, built at the project's root by /// `build_policy`. Returns the version in force. fn record_policy( @@ -340,6 +469,18 @@ fn record_policy( request_id: RequestId, at: &str, ) -> Result { + record_version(store, project, recorded, request_id, at) + .map_err(|error| display::store_error(&error, Some(&project.id))) +} + +/// The policy step as the store reports it, so a caller words its own failure. +fn record_version( + store: &(impl Admin + Views + Ledger), + project: &Project, + recorded: &RecordedPolicy, + request_id: RequestId, + at: &str, +) -> Result { policy_step::step( store, &ProjectId(project.id.clone()), @@ -348,7 +489,6 @@ fn record_policy( at, None, ) - .map_err(|error| display::store_error(&error, Some(&project.id))) } /// The project the working directory is in, with its project file as read. @@ -955,4 +1095,145 @@ mod tests { assert_eq!((render.code, render.error), (3, true)); assert!(render.lines[0].contains("disk full"), "{:?}", render.lines); } + /// The global file in `dir` as `prepare` judges it outside a project, and + /// what the interview showed of it. + fn global_in(dir: &Path, text: Option<&str>) -> (Prepared, Expected) { + let path = dir.join("config.toml"); + if let Some(text) = text { + std::fs::write(&path, text).unwrap(); + } + let reads = Reads { + global: settings::read(&path), + head: None, + }; + let prepared = prepare(FileLayer::Global, &path, None, &reads, Schema::standard()).unwrap(); + let shown = Expected { + path, + digest: prepared.base.as_ref().map(|file| file.digest.clone()), + }; + (prepared, shown) + } + + fn reviewer_list(names: &[&str]) -> TypedPair { + TypedPair { + name: "review.reviewers".to_owned(), + host: None, + value: baley_core::policy::Value::ReviewerList( + names.iter().map(|name| (*name).to_owned()).collect(), + ), + } + } + + const OWNER_FILE: &str = "escalate_on_failure = true\nextra = 1\n\n[updates]\nauto = true\n\n[review]\nreviewers = [\"host\"]\nnote = \"kept\"\n"; + + #[test] + fn a_reviewer_list_write_that_loses_another_setting_or_misreports_its_digest_is_caught() { + let dir = tempfile::tempdir().unwrap(); + let (prepared, shown) = global_in(dir.path(), Some(OWNER_FILE)); + + let digest = replace_list( + &prepared, + &shown, + &reviewer_list(&["host", "openai"]), + dir.path(), + ) + .unwrap(); + + let bytes = std::fs::read(&shown.path).unwrap(); + let text = String::from_utf8(bytes).unwrap(); + for kept in [ + "escalate_on_failure = true", + "extra = 1", + "auto = true", + "note = \"kept\"", + ] { + assert!(text.contains(kept), "{kept} lost from {text}"); + } + let written = settings::read(&shown.path).unwrap().unwrap(); + let layer = parse_layer(&written, FileLayer::Global, Schema::standard()).unwrap(); + let listed: Vec<_> = layer + .values + .iter() + .filter(|value| value.name == "review.reviewers") + .map(|value| value.value.clone()) + .collect(); + assert_eq!( + listed, + [baley_core::policy::Value::ReviewerList(vec![ + "host".to_owned(), + "openai".to_owned() + ])] + ); + assert_eq!(digest, crate::providers::fixtures::sha256_hex(&text)); + } + + #[test] + fn a_reviewer_list_written_over_a_global_file_changed_since_it_was_shown_is_caught() { + let dir = tempfile::tempdir().unwrap(); + let (prepared, shown) = global_in(dir.path(), Some(OWNER_FILE)); + let other_bytes = Expected { + path: shown.path.clone(), + digest: Some(settings::file(&shown.path, b"other = 1\n".to_vec()).digest), + }; + let was_absent = Expected { + path: shown.path.clone(), + digest: None, + }; + + for (case, shown) in [("other bytes", other_bytes), ("absent", was_absent)] { + let refused = replace_list( + &prepared, + &shown, + &reviewer_list(&["host", "openai"]), + dir.path(), + ) + .unwrap_err(); + + assert_eq!(refused.code, 2, "{case}"); + assert!( + refused.lines[0].starts_with("config-conflict: "), + "{case}: {refused:?}" + ); + assert!( + refused.lines[0].contains("changed since it was shown; run the interview again"), + "{case}: {refused:?}" + ); + assert_eq!( + std::fs::read_to_string(&prepared.target).unwrap(), + OWNER_FILE + ); + } + } + + #[test] + fn a_reviewer_list_written_through_a_linked_global_file_is_caught() { + let dir = tempfile::tempdir().unwrap(); + let real = dir.path().join("real.toml"); + std::fs::write(&real, OWNER_FILE).unwrap(); + symlink(&real, dir.path().join("config.toml")).unwrap(); + let (prepared, shown) = global_in(dir.path(), None); + + let refused = replace_list( + &prepared, + &shown, + &reviewer_list(&["host", "openai"]), + dir.path(), + ) + .unwrap_err(); + + assert_eq!(refused.code, 2); + assert!( + refused.lines[0].starts_with("config-conflict: "), + "{refused:?}" + ); + assert!(refused.lines[0].contains("config.toml"), "{refused:?}"); + assert!(refused.lines[0].contains("symbolic link"), "{refused:?}"); + assert_eq!(std::fs::read_to_string(&real).unwrap(), OWNER_FILE); + assert!( + std::fs::symlink_metadata(dir.path().join("config.toml")) + .unwrap() + .file_type() + .is_symlink() + ); + } } From 5e8090a91a0a5d28be5f962661379ce6bb5bc84f Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 19:54:01 -0400 Subject: [PATCH 14/27] feat(config): plan which confirmed writes run and which are already done A rerun with the same answers plans no acknowledgement, no host and no list, and a settings file that cannot take the hosts refuses before anything is shown or written. --- crates/baley/src/config_command/interview.rs | 8 + .../src/config_command/interview/writes.rs | 294 ++++++++++++++++++ crates/baley/src/install/hosts.rs | 19 +- 3 files changed, 315 insertions(+), 6 deletions(-) create mode 100644 crates/baley/src/config_command/interview/writes.rs diff --git a/crates/baley/src/config_command/interview.rs b/crates/baley/src/config_command/interview.rs index 37ed685f..4ff57c43 100644 --- a/crates/baley/src/config_command/interview.rs +++ b/crates/baley/src/config_command/interview.rs @@ -17,6 +17,14 @@ use std::io::{self, BufRead, Write}; use std::path::{Path, PathBuf}; mod providers; +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "the interview runs the plan once the ordered writes are wired" + ) +)] +mod writes; use self::providers::{Acknowledge, Refusal, Staged}; use super::reviewers::{self, Selection}; diff --git a/crates/baley/src/config_command/interview/writes.rs b/crates/baley/src/config_command/interview/writes.rs new file mode 100644 index 00000000..7d6708f7 --- /dev/null +++ b/crates/baley/src/config_command/interview/writes.rs @@ -0,0 +1,294 @@ +//! Which confirmed writes run after the owner's final `yes` (design 0003 +//! section 5, Confirmed writes). It is one pure plan over the owner's answers +//! and what the gatherer read, in the order the writes run: the acknowledgement, +//! the API-host allowance, the reviewer list, then the settings. Each step is +//! planned only when it has something to change. + +use std::path::PathBuf; + +use baley_core::catalog::Provider; +use baley_core::policy::config_command::TypedPair; +use baley_store::NewEvent; +use serde_json::Value; + +use super::providers::{Acknowledge, Answer}; +use crate::config_command::reviewers; +use crate::install::hosts::{self, Allowance, ClaudeSettings}; +use crate::providers::record; + +/// What the acknowledgement step does. +#[derive(Debug, Clone, PartialEq)] +pub(super) enum Ack { + /// The providers were confirmed, and the `providers` stream is not read + /// yet, which is so until the owner's final `yes`. + Requested(Acknowledge), + /// No stored acknowledgement covers the providers, so this event records + /// one. + Record(Acknowledge, Box), + /// A current stored acknowledgement already covers the providers. + Reused(Acknowledge), +} + +/// The host step: the allowance to write into Claude Code's settings file. +#[derive(Debug, Clone, PartialEq)] +pub(super) struct Hosts { + /// The settings file's path. + pub(super) path: PathBuf, + /// The decided document, its bytes and the hosts it adds. + pub(super) allowance: Allowance, +} + +/// The `providers` stream as it was read after the final `yes`. +pub(super) struct Stream<'a> { + /// Each stored `providers.acknowledged` payload. + pub(super) payloads: &'a [Value], + /// The time a new acknowledgement is recorded at. + pub(super) at: &'a str, +} + +/// What the plan is judged from. +pub(super) struct Input<'a> { + /// The owner's answer to the provider question. + pub(super) answer: &'a Answer, + /// The confirmed providers and the owner's name, when providers were chosen. + pub(super) acknowledgement: Option<&'a Acknowledge>, + /// The global file's top-level `review.reviewers` as written, or none. + pub(super) stored: Option<&'a [String]>, + /// Claude Code's settings file as read, or why it could not be. + pub(super) claude: &'a Result, + /// The `providers` stream, once read; none before the final `yes`. + pub(super) stream: Option>, + /// The non-blank settings answers. + pub(super) settings: &'a [(String, String)], +} + +/// The steps to run, each present only when it has something to change. +#[derive(Debug, Clone, PartialEq)] +pub(super) struct Plan { + /// Step 1. + pub(super) acknowledgement: Option, + /// Step 2. + pub(super) hosts: Option, + /// Step 3: the whole `review.reviewers` list to write into the global file. + pub(super) reviewers: Option, + /// Step 4: the settings answers, which `config set` judges for itself. + pub(super) settings: Vec<(String, String)>, +} + +/// Why the interview stops before its summary, as the line the owner sees. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct Refused(pub(super) String); + +/// Plans the confirmed writes. A Claude settings file that cannot be read or +/// cannot take the hosts refuses the whole interview when a provider was +/// chosen, so nothing has been written when the owner is told. Without a +/// provider the file is not consulted. +pub(super) fn plan(input: &Input<'_>) -> Result { + let chosen: &[Provider] = match input.answer { + Answer::Choose(chosen) => chosen, + Answer::Keep => &[], + }; + let hosts = if chosen.is_empty() { + None + } else { + let claude = input + .claude + .as_ref() + .map_err(|line| Refused(line.clone()))?; + hosts::judge(&claude.path, &claude.current, chosen) + .map_err(Refused)? + .map(|allowance| Hosts { + path: claude.path.clone(), + allowance, + }) + }; + let acknowledgement = input + .acknowledgement + .map(|acknowledgement| match &input.stream { + None => Ack::Requested(acknowledgement.clone()), + Some(stream) => match record::judge( + stream.payloads, + &acknowledgement.providers, + &acknowledgement.owner, + stream.at, + ) { + Some(event) => Ack::Record(acknowledgement.clone(), Box::new(event)), + None => Ack::Reused(acknowledgement.clone()), + }, + }); + let choice = match input.answer { + Answer::Choose(chosen) => Some(chosen.as_slice()), + Answer::Keep => None, + }; + Ok(Plan { + acknowledgement, + hosts, + reviewers: reviewers::decide(input.stored, choice), + settings: input.settings.to_vec(), + }) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::install::settings::Current; + use crate::providers::fixtures::{DESIGN_WARNING, sha256_hex}; + + const AT: &str = "2026-10-10T09:00:00Z"; + const SETTINGS: &str = "/home/o/.claude/settings.json"; + + fn claude(document: Value) -> Result { + Ok(ClaudeSettings { + path: SETTINGS.into(), + current: Current { + document, + symbolic_link: false, + read_digest: Some("read".to_owned()), + }, + }) + } + + fn ada(providers: &[Provider]) -> Acknowledge { + Acknowledge { + providers: providers.to_vec(), + owner: "Ada Owner".to_owned(), + } + } + + fn names(names: &[&str]) -> Vec { + names.iter().map(|name| (*name).to_owned()).collect() + } + + fn effort_max() -> Vec<(String, String)> { + vec![("roles.planner.effort".to_owned(), "max".to_owned())] + } + + /// A stored acknowledgement of `providers` at the current warning. + fn stored_ack(providers: &[&str]) -> Value { + json!({ + "providers": providers, + "warning_version": 1, + "warning_sha256": sha256_hex(DESIGN_WARNING), + "owner": "Ada Owner", + "acknowledged_at": "2026-10-01T09:00:00Z", + }) + } + + #[test] + fn a_rerun_of_a_completed_interview_that_writes_or_acknowledges_again_is_caught() { + let answer = Answer::Choose(vec![Provider::OpenAi]); + let acknowledgement = ada(&[Provider::OpenAi]); + let stored = names(&["host", "openai"]); + let claude = + claude(json!({"sandbox": {"network": {"allowedDomains": ["api.openai.com"]}}})); + let payloads = [stored_ack(&["openai"])]; + let settings = effort_max(); + + let plan = plan(&Input { + answer: &answer, + acknowledgement: Some(&acknowledgement), + stored: Some(&stored), + claude: &claude, + stream: Some(Stream { + payloads: &payloads, + at: AT, + }), + settings: &settings, + }) + .unwrap(); + + assert_eq!(plan.acknowledgement, Some(Ack::Reused(acknowledgement))); + assert_eq!(plan.hosts, None); + assert_eq!(plan.reviewers, None); + assert_eq!(plan.settings, effort_max()); + } + /// The plan before the final `yes`, with no settings answers unless given. + fn before_yes( + answer: &Answer, + stored: Option<&[String]>, + claude: &Result, + settings: &[(String, String)], + ) -> Result { + let providers = match answer { + Answer::Choose(chosen) if !chosen.is_empty() => Some(ada(chosen)), + _ => None, + }; + plan(&Input { + answer, + acknowledgement: providers.as_ref(), + stored, + claude, + stream: None, + settings, + }) + } + + #[test] + fn a_blank_provider_answer_planning_a_provider_write_is_caught() { + let stored = names(&["host", "openai"]); + let claude = claude(json!({"model": "opus"})); + let settings = effort_max(); + + let plan = before_yes(&Answer::Keep, Some(&stored), &claude, &settings).unwrap(); + + assert_eq!(plan.acknowledgement, None); + assert_eq!(plan.hosts, None); + assert_eq!(plan.reviewers, None); + assert_eq!(plan.settings, effort_max()); + } + + #[test] + fn an_explicit_none_that_leaves_the_stored_providers_is_caught() { + let claude = claude(json!({})); + let none = Answer::Choose(Vec::new()); + for (stored, expected) in [ + (names(&["host", "openai"]), names(&["host"])), + (names(&["openai"]), Vec::new()), + ] { + let plan = before_yes(&none, Some(&stored), &claude, &[]).unwrap(); + + let Some(pair) = &plan.reviewers else { + panic!("no reviewer step for {stored:?}"); + }; + assert_eq!(pair.name, "review.reviewers"); + assert_eq!( + pair.value, + baley_core::policy::Value::ReviewerList(expected), + "{stored:?}" + ); + assert_eq!(plan.acknowledgement, None, "{stored:?}"); + assert_eq!(plan.hosts, None, "{stored:?}"); + assert_eq!(plan.settings, Vec::new(), "{stored:?}"); + } + } + + #[test] + fn a_settings_file_that_cannot_take_hosts_reaching_the_summary_is_caught() { + let refusal = "install-settings-conflict: /home/o/.claude/settings.json is JSON but not an object; fix it by hand, then run baley install again".to_owned(); + let unreadable: Result = Err(refusal.clone()); + let openai = Answer::Choose(vec![Provider::OpenAi]); + + let refused = before_yes(&openai, None, &unreadable, &[]); + assert_eq!(refused, Err(Refused(refusal.clone()))); + + let object = claude(json!({"sandbox": {"network": {"allowedDomains": {"a": 1}}}})); + let Err(Refused(line)) = before_yes(&openai, None, &object, &[]) else { + panic!("an allowedDomains object was planned"); + }; + assert!(line.starts_with("install-settings-conflict: "), "{line}"); + + let blank = before_yes(&Answer::Keep, None, &unreadable, &[]).unwrap(); + assert_eq!(blank.acknowledgement, None); + assert_eq!(blank.hosts, None); + assert_eq!(blank.reviewers, None); + + let stored = names(&["host", "openai"]); + let none = Answer::Choose(Vec::new()); + let only_list = before_yes(&none, Some(&stored), &unreadable, &[]).unwrap(); + assert_eq!(only_list.acknowledgement, None); + assert_eq!(only_list.hosts, None); + assert!(only_list.reviewers.is_some()); + } +} diff --git a/crates/baley/src/install/hosts.rs b/crates/baley/src/install/hosts.rs index 2f4378a4..2ebd1563 100644 --- a/crates/baley/src/install/hosts.rs +++ b/crates/baley/src/install/hosts.rs @@ -28,6 +28,8 @@ pub struct Allowance { /// SHA-256 of the bytes the document was read from, or none when the file /// was absent. pub read_digest: Option, + /// The hosts this write adds, in the order they are appended. + pub added: Vec<&'static str>, } /// The key that holds the list of allowed hosts. @@ -51,9 +53,12 @@ fn object<'a>( } /// Appends the wanted hosts the list does not hold, making `sandbox`, -/// `sandbox.network` and the list when they are missing. Whether it added any, +/// `sandbox.network` and the list when they are missing. The hosts it added, /// or the key that holds the wrong kind of value. -fn add_hosts(document: &mut Value, wanted: &[&str]) -> Result { +fn add_hosts( + document: &mut Value, + wanted: &[&'static str], +) -> Result, &'static str> { let empty = || Value::Object(Map::new()); let root = object(document, "sandbox")?; let sandbox = object(root.entry("sandbox").or_insert_with(empty), "sandbox")?; @@ -67,13 +72,13 @@ fn add_hosts(document: &mut Value, wanted: &[&str]) -> Result = wanted + let missing: Vec<&'static str> = wanted .iter() .copied() .filter(|host| !list.iter().any(|held| held.as_str() == Some(host))) .collect(); list.extend(missing.iter().map(|host| Value::String((*host).to_owned()))); - Ok(!missing.is_empty()) + Ok(missing) } /// Decides what to write so each chosen provider's API host is allowed. Hosts @@ -88,7 +93,7 @@ pub fn judge( current: &Current, chosen: &[Provider], ) -> Result, String> { - let wanted: Vec<&str> = Provider::ALL + let wanted: Vec<&'static str> = Provider::ALL .into_iter() .filter(|provider| chosen.contains(provider)) .map(Provider::api_host) @@ -97,7 +102,8 @@ pub fn judge( return Ok(None); } let mut document = current.document.clone(); - if !add_hosts(&mut document, &wanted).map_err(|key| not_composable(path, key))? { + let added = add_hosts(&mut document, &wanted).map_err(|key| not_composable(path, key))?; + if added.is_empty() { return Ok(None); } if current.symbolic_link { @@ -110,6 +116,7 @@ pub fn judge( document, bytes, read_digest: current.read_digest.clone(), + added, })) } From 02ce17d86476d2f6624dbdeb81b35d52531e5ca5 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 20:01:01 -0400 Subject: [PATCH 15/27] feat(config): list every file the confirmed writes can change in the summary The ledger's five files, each settings file and its temporary file, and the folders that may be created are named at their resolved paths, and only for the steps that will run. --- crates/baley/src/config_command/interview.rs | 8 + .../src/config_command/interview/summary.rs | 412 ++++++++++++++++++ 2 files changed, 420 insertions(+) create mode 100644 crates/baley/src/config_command/interview/summary.rs diff --git a/crates/baley/src/config_command/interview.rs b/crates/baley/src/config_command/interview.rs index 4ff57c43..5dcc7b48 100644 --- a/crates/baley/src/config_command/interview.rs +++ b/crates/baley/src/config_command/interview.rs @@ -17,6 +17,14 @@ use std::io::{self, BufRead, Write}; use std::path::{Path, PathBuf}; mod providers; +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "the interview prints the summary once the ordered writes are wired" + ) +)] +mod summary; #[cfg_attr( not(test), expect( diff --git a/crates/baley/src/config_command/interview/summary.rs b/crates/baley/src/config_command/interview/summary.rs new file mode 100644 index 00000000..50dd0b63 --- /dev/null +++ b/crates/baley/src/config_command/interview/summary.rs @@ -0,0 +1,412 @@ +//! The summary the owner reads before the final question (design 0003 +//! section 5, Confirmed writes). It is pure over the planned steps and the +//! paths they write to, and it names every file those steps can change or +//! create at its resolved path. + +use std::path::{Path, PathBuf}; + +use baley_core::policy::config_command::TypedPair; +use baley_core::policy::{FileLayer, Host, Value}; + +use super::writes::{Ack, Plan}; +use crate::providers::warning; + +/// Where the planned writes land. +pub(super) struct Where<'a> { + /// Baley's home, which holds the ledger. + pub(super) home: &'a Path, + /// The global settings file. + pub(super) global: &'a Path, + /// The file the settings answers are written to. + pub(super) target: &'a Path, + /// Which file that is. + pub(super) layer: FileLayer, + /// The host section the settings answers go to, `None` for the top level. + pub(super) host: Option, + /// Whether the working directory is in a managed project. + pub(super) in_project: bool, +} + +/// The ledger's files in Baley's home, as the store names them. +const LEDGER_FILES: [&str; 5] = [ + "baley.db", + "baley.db-wal", + "baley.db-shm", + "baley.db.writer", + "baley.db.maintenance", +]; + +/// A temporary file as `replace` names it, with the process id and the counter +/// left as placeholders since neither is known before the write. +fn temporary(file: &Path) -> PathBuf { + let name = file.file_name().map(|name| name.to_string_lossy()); + let name = format!(".{}.-.tmp", name.as_deref().unwrap_or("")); + file.parent() + .map_or_else(|| PathBuf::from(&name), |folder| folder.join(&name)) +} + +/// Adds `path` unless it is listed already, so a file two steps share is named +/// once. +fn add(paths: &mut Vec, path: PathBuf) { + if !paths.contains(&path) { + paths.push(path); + } +} + +/// The files the planned steps can change or create, and the folders that may +/// be created for them. A step that is not planned adds nothing. +fn files(plan: &Plan, at: &Where<'_>) -> (Vec, Vec) { + let (mut files, mut folders) = (Vec::new(), Vec::new()); + let settings = !plan.settings.is_empty(); + // The ledger opens for the acknowledgement, for a catalog seed when a + // model is set, and for the policy step of a write in a project. + let ledger = plan.acknowledgement.is_some() + || plan + .settings + .iter() + .any(|(name, _)| name.ends_with(".model")) + || (at.in_project && (plan.reviewers.is_some() || settings)); + if ledger { + for name in LEDGER_FILES { + add(&mut files, at.home.join(name)); + } + add(&mut folders, at.home.to_path_buf()); + } + if let Some(hosts) = &plan.hosts { + add(&mut files, hosts.path.clone()); + add(&mut files, temporary(&hosts.path)); + if let Some(folder) = hosts.path.parent() { + add(&mut folders, folder.to_path_buf()); + } + } + if plan.reviewers.is_some() || (settings && at.layer == FileLayer::Global) { + add(&mut files, at.global.to_path_buf()); + add(&mut files, temporary(at.global)); + if let Some(folder) = at.global.parent() { + add(&mut folders, folder.to_path_buf()); + } + } + if settings && at.layer == FileLayer::Project { + add(&mut files, at.target.to_path_buf()); + add(&mut files, temporary(at.target)); + } + (files, folders) +} + +/// `["host", "openai"]`, as the list is written to the file. +fn list_text(names: &[String]) -> String { + let quoted: Vec = names.iter().map(|name| format!("\"{name}\"")).collect(); + format!("[{}]", quoted.join(", ")) +} + +/// The summary's lines in order: the providers and their warning version, the +/// API hosts, the reviewer list, every file and folder the writes can touch, +/// and last the settings block when there are settings answers. +pub(super) fn lines(plan: &Plan, at: &Where<'_>) -> Vec { + let mut lines = Vec::new(); + if let Some(Ack::Requested(who) | Ack::Record(who, _) | Ack::Reused(who)) = + &plan.acknowledgement + { + let names: Vec<&str> = who + .providers + .iter() + .map(|provider| provider.name()) + .collect(); + lines.push(format!( + "Outside review providers confirmed: {} (warning version {})", + names.join(", "), + warning::WARNING_VERSION + )); + } + if let Some(hosts) = &plan.hosts { + lines.push(format!( + "API hosts to allow in {} (sandbox.network.allowedDomains): {}", + hosts.path.display(), + hosts.allowance.added.join(", ") + )); + } + if let Some(TypedPair { + value: Value::ReviewerList(names), + .. + }) = &plan.reviewers + { + lines.push(format!( + "review.reviewers will be {} in {}", + list_text(names), + at.global.display() + )); + } + let (files, folders) = files(plan, at); + if !files.is_empty() { + lines.push("Files these writes can change or create:".to_owned()); + lines.extend(files.iter().map(|file| format!(" {}", file.display()))); + lines.push( + " ( and stand for numbers chosen when the file is written)".to_owned(), + ); + lines.push("Folders that may be created:".to_owned()); + lines.extend( + folders + .iter() + .map(|folder| format!(" {}", folder.display())), + ); + } + if !plan.settings.is_empty() { + let section = at + .host + .map_or_else(String::new, |host| format!(" [host.{}]", host.name())); + lines.push(format!( + "The interview will write to {}{section}", + at.target.display() + )); + lines.extend( + plan.settings + .iter() + .map(|(name, value)| format!(" {name}={value}")), + ); + } + lines +} + +#[cfg(test)] +mod tests { + use baley_core::catalog::Provider; + use baley_core::policy::Value; + use baley_core::policy::config_command::TypedPair; + use serde_json::json; + + use super::*; + use crate::config_command::interview::providers::Acknowledge; + use crate::config_command::interview::writes::{Ack, Hosts}; + use crate::install::hosts::Allowance; + + const HOME: &str = "/u/.local/share/crenshawdev/baley"; + const GLOBAL: &str = "/u/.config/crenshawdev/baley/config.toml"; + const CLAUDE: &str = "/u/.claude/settings.json"; + + fn acknowledgement(providers: &[Provider]) -> Ack { + Ack::Requested(Acknowledge { + providers: providers.to_vec(), + owner: "Ada Owner".to_owned(), + }) + } + + fn hosts(added: &[&'static str]) -> Hosts { + Hosts { + path: CLAUDE.into(), + allowance: Allowance { + document: json!({}), + bytes: Vec::new(), + read_digest: None, + added: added.to_vec(), + }, + } + } + + fn list(names: &[&str]) -> TypedPair { + TypedPair { + name: "review.reviewers".to_owned(), + host: None, + value: Value::ReviewerList(names.iter().map(|name| (*name).to_owned()).collect()), + } + } + + fn answer(name: &str, value: &str) -> Vec<(String, String)> { + vec![(name.to_owned(), value.to_owned())] + } + + fn provider_steps() -> Plan { + Plan { + acknowledgement: Some(acknowledgement(&[Provider::OpenAi])), + hosts: Some(hosts(&["api.openai.com"])), + reviewers: Some(list(&["host", "openai"])), + settings: Vec::new(), + } + } + + fn global_at() -> Where<'static> { + Where { + home: Path::new(HOME), + global: Path::new(GLOBAL), + target: Path::new(GLOBAL), + layer: FileLayer::Global, + host: None, + in_project: false, + } + } + + /// How many lines are exactly `path`, indented as the files block does. + fn listed(lines: &[String], path: impl AsRef) -> usize { + let line = format!(" {}", path.as_ref().display()); + lines.iter().filter(|candidate| **candidate == line).count() + } + + fn folder_of(path: &str) -> PathBuf { + Path::new(path).parent().unwrap().to_path_buf() + } + + #[test] + fn a_file_the_confirmed_writes_can_change_missing_from_the_summary_is_caught() { + let mut plan = provider_steps(); + plan.settings = answer("roles.planner.effort", "max"); + + let lines = lines(&plan, &global_at()); + + for ledger in [ + "baley.db", + "baley.db-wal", + "baley.db-shm", + "baley.db.writer", + "baley.db.maintenance", + ] { + assert_eq!(listed(&lines, Path::new(HOME).join(ledger)), 1, "{ledger}"); + } + assert_eq!(listed(&lines, CLAUDE), 1); + assert_eq!( + listed(&lines, "/u/.claude/.settings.json.-.tmp"), + 1 + ); + assert_eq!(listed(&lines, GLOBAL), 1); + assert_eq!( + listed( + &lines, + "/u/.config/crenshawdev/baley/.config.toml.-.tmp" + ), + 1 + ); + for folder in [Path::new(HOME), &folder_of(GLOBAL), &folder_of(CLAUDE)] { + assert_eq!(listed(&lines, folder), 1, "{}", folder.display()); + } + + let mut plan = provider_steps(); + plan.settings = answer("roles.planner.effort", "max"); + let in_project = Where { + target: Path::new("/r/baley.toml"), + layer: FileLayer::Project, + in_project: true, + ..global_at() + }; + + let lines = super::lines(&plan, &in_project); + + assert_eq!(listed(&lines, "/r/baley.toml"), 1); + assert_eq!(listed(&lines, "/r/.baley.toml.-.tmp"), 1); + assert_eq!(listed(&lines, GLOBAL), 1); + } + /// Whether some line holds every one of `parts`. + fn names_together(lines: &[String], parts: &[&str]) -> bool { + lines + .iter() + .any(|line| parts.iter().all(|part| line.contains(part))) + } + + #[test] + fn a_summary_missing_the_hosts_or_reviewer_list_it_will_write_is_caught() { + let mut plan = provider_steps(); + plan.settings = answer("roles.planner.effort", "max"); + + let lines = lines(&plan, &global_at()); + + assert!(names_together(&lines, &["openai", "warning version 1"])); + assert!(names_together(&lines, &["api.openai.com", CLAUDE])); + assert!(names_together(&lines, &["[\"host\", \"openai\"]", GLOBAL])); + assert!(lines.contains(&" roles.planner.effort=max".to_owned())); + + let plan = Plan { + acknowledgement: Some(acknowledgement(&Provider::ALL)), + hosts: Some(hosts(&["api.openai.com", "api.deepseek.com"])), + reviewers: Some(list(&["host", "openai", "deepseek"])), + settings: Vec::new(), + }; + + let lines = super::lines(&plan, &global_at()); + + assert!(names_together( + &lines, + &["openai", "deepseek", "warning version 1"] + )); + assert!(names_together( + &lines, + &["api.openai.com", "api.deepseek.com", CLAUDE] + )); + assert!(names_together( + &lines, + &["[\"host\", \"openai\", \"deepseek\"]", GLOBAL] + )); + } + + #[test] + fn a_file_listed_for_a_step_that_will_not_run_is_caught() { + let ledger = |lines: &[String]| { + [ + "baley.db", + "baley.db-wal", + "baley.db-shm", + "baley.db.writer", + "baley.db.maintenance", + ] + .into_iter() + .filter(|name| listed(lines, Path::new(HOME).join(name)) == 1) + .count() + }; + let only = |settings: Vec<(String, String)>, hosts: Option| Plan { + acknowledgement: None, + hosts, + reviewers: None, + settings, + }; + + let rung = lines( + &only(answer("roles.planner.effort", "max"), None), + &global_at(), + ); + assert_eq!(listed(&rung, GLOBAL), 1); + assert_eq!( + listed( + &rung, + "/u/.config/crenshawdev/baley/.config.toml.-.tmp" + ), + 1 + ); + assert_eq!(listed(&rung, folder_of(GLOBAL)), 1); + assert_eq!(ledger(&rung), 0); + assert_eq!(listed(&rung, CLAUDE), 0); + assert_eq!(listed(&rung, HOME), 0); + + let host_only = lines( + &only(Vec::new(), Some(hosts(&["api.openai.com"]))), + &global_at(), + ); + assert_eq!(listed(&host_only, CLAUDE), 1); + assert_eq!( + listed(&host_only, "/u/.claude/.settings.json.-.tmp"), + 1 + ); + assert_eq!(listed(&host_only, folder_of(CLAUDE)), 1); + assert_eq!(listed(&host_only, GLOBAL), 0); + assert_eq!(ledger(&host_only), 0); + + let model = lines( + &only(answer("roles.planner.model", "sonnet"), None), + &global_at(), + ); + assert_eq!(ledger(&model), 5); + assert_eq!(listed(&model, HOME), 1); + assert_eq!(listed(&model, GLOBAL), 1); + + let project = Where { + target: Path::new("/r/baley.toml"), + layer: FileLayer::Project, + in_project: true, + ..global_at() + }; + let in_project = lines(&only(answer("roles.planner.effort", "max"), None), &project); + assert_eq!(ledger(&in_project), 5); + assert_eq!(listed(&in_project, HOME), 1); + assert_eq!(listed(&in_project, "/r/baley.toml"), 1); + assert_eq!( + listed(&in_project, "/r/.baley.toml.-.tmp"), + 1 + ); + assert_eq!(listed(&in_project, GLOBAL), 0); + } +} From aaebccb7b79cdf25c8c6b6bc03c260922b9c73b4 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 20:06:39 -0400 Subject: [PATCH 16/27] feat(config): stop the ordered writes at a failure and report what is done The failing step's own lines lead with its code, then the writes already completed and the steps never started, so a rerun can finish only what is missing. --- crates/baley/src/config_command/interview.rs | 8 + .../src/config_command/interview/report.rs | 399 ++++++++++++++++++ 2 files changed, 407 insertions(+) create mode 100644 crates/baley/src/config_command/interview/report.rs diff --git a/crates/baley/src/config_command/interview.rs b/crates/baley/src/config_command/interview.rs index 5dcc7b48..b97eb401 100644 --- a/crates/baley/src/config_command/interview.rs +++ b/crates/baley/src/config_command/interview.rs @@ -17,6 +17,14 @@ use std::io::{self, BufRead, Write}; use std::path::{Path, PathBuf}; mod providers; +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "the interview runs the steps once the ordered writes are wired" + ) +)] +mod report; #[cfg_attr( not(test), expect( diff --git a/crates/baley/src/config_command/interview/report.rs b/crates/baley/src/config_command/interview/report.rs new file mode 100644 index 00000000..8405ca8c --- /dev/null +++ b/crates/baley/src/config_command/interview/report.rs @@ -0,0 +1,399 @@ +//! The ordered writes after the final `yes`: which step runs next, where the +//! run stops and what the owner is told (design 0003 section 5, Confirmed +//! writes). It is pure over the planned steps and the outcome of each step as +//! it arrives, so it is tested without a store or a file. + +use std::path::Path; + +use baley_core::policy::Value; +use baley_core::policy::config_command::TypedPair; + +use super::writes::{Ack, Plan}; +use crate::ledger::display::Render; +use crate::providers::warning; + +/// One of the four writes, in the order they run. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum Step { + /// Record the acknowledgement unless a current one covers it. + Acknowledgement, + /// Add the API hosts to Claude Code's settings file. + Hosts, + /// Write the global `review.reviewers` list. + Reviewers, + /// Write the settings answers through `config set`. + Settings, +} + +/// What a step that succeeded did. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum Done { + /// The acknowledgement was recorded, or a current one already covered it. + Acknowledgement { + /// Whether an event was appended. + recorded: bool, + }, + /// The hosts were added. The note is the sync failure's text when the file + /// was replaced but its folder could not be synced. + Hosts { + /// The sync failure's text, if any. + note: Option, + }, + /// The list was written. The version is the policy version the step + /// recorded in a project that is in the ledger. + Reviewers { + /// The policy version, if the step ran. + version: Option, + }, + /// `config set` wrote the settings, and printed these lines. + Settings(Vec), +} + +/// What a step did: its success, or the failure it printed with its code. +pub(super) type Outcome = Result; + +/// The steps the plan runs, in order. +fn steps(plan: &Plan) -> Vec { + let mut steps = Vec::new(); + if plan.acknowledgement.is_some() { + steps.push(Step::Acknowledgement); + } + if plan.hosts.is_some() { + steps.push(Step::Hosts); + } + if plan.reviewers.is_some() { + steps.push(Step::Reviewers); + } + if !plan.settings.is_empty() { + steps.push(Step::Settings); + } + steps +} + +/// The step to run after the outcomes so far: the next planned one, or none +/// when every step has run or one failed. +pub(super) fn next(plan: &Plan, outcomes: &[Outcome]) -> Option { + if outcomes.iter().any(Result::is_err) { + return None; + } + steps(plan).get(outcomes.len()).copied() +} + +/// The owner's words for a step that was not started. +fn unwritten(step: Step) -> &'static str { + match step { + Step::Acknowledgement => "the acknowledgement", + Step::Hosts => "the API hosts", + Step::Reviewers => "review.reviewers", + Step::Settings => "the settings", + } +} + +/// The lines for one completed write. +fn written(plan: &Plan, global: &Path, done: &Done) -> Vec { + match done { + Done::Acknowledgement { recorded } => { + let Some(Ack::Requested(who) | Ack::Record(who, _) | Ack::Reused(who)) = + &plan.acknowledgement + else { + return Vec::new(); + }; + let names: Vec<&str> = who + .providers + .iter() + .map(|provider| provider.name()) + .collect(); + let was = if *recorded { + "recorded" + } else { + "already recorded" + }; + vec![format!( + "The acknowledgement of {} at warning version {} was {was}.", + names.join(", "), + warning::WARNING_VERSION + )] + } + Done::Hosts { note } => { + let Some(hosts) = &plan.hosts else { + return Vec::new(); + }; + let mut lines = vec![format!( + "Allowed {} in {} (sandbox.network.allowedDomains).", + hosts.allowance.added.join(", "), + hosts.path.display() + )]; + lines.extend(note.clone()); + lines + } + Done::Reviewers { version } => { + let Some(TypedPair { + value: Value::ReviewerList(names), + .. + }) = &plan.reviewers + else { + return Vec::new(); + }; + let quoted: Vec = names.iter().map(|name| format!("\"{name}\"")).collect(); + let policy = version.map_or_else(String::new, |version| { + format!(" (policy version {version})") + }); + vec![format!( + "Wrote review.reviewers = [{}] to {}{policy}.", + quoted.join(", "), + global.display() + )] + } + Done::Settings(lines) => lines.clone(), + } +} + +/// What the owner is told once the run ends, and how it exits. A run that +/// stopped at a failure prints that step's own lines first, with its code, +/// then each write already completed, then the steps never started and how to +/// finish them. A run with no failure prints each step's line and exits 0. +pub(super) fn report(plan: &Plan, global: &Path, outcomes: &[Outcome]) -> Render { + let mut completed = Vec::new(); + let mut failure = None; + for outcome in outcomes { + match outcome { + Ok(done) => completed.extend(written(plan, global, done)), + Err(render) => failure = Some(render), + } + } + let Some(failure) = failure else { + if outcomes.is_empty() { + return Render::line("Nothing changed: every answer kept the value in force.", 0); + } + let nothing_new = outcomes + .iter() + .all(|outcome| matches!(outcome, Ok(Done::Acknowledgement { recorded: false }))); + if nothing_new { + completed.push("No file was changed.".to_owned()); + } + return Render { + lines: completed, + code: 0, + error: false, + }; + }; + let mut lines = failure.lines.clone(); + lines.extend(completed); + let not_started: Vec<&str> = steps(plan) + .into_iter() + .skip(outcomes.len()) + .map(unwritten) + .collect(); + if !not_started.is_empty() { + lines.push(format!("Not written: {}.", not_started.join(", "))); + } + lines.push("Run the interview again: it finishes only what is missing.".to_owned()); + Render { + lines, + code: failure.code, + error: true, + } +} + +#[cfg(test)] +mod tests { + use baley_core::catalog::Provider; + use baley_core::policy::Value; + use baley_core::policy::config_command::TypedPair; + use serde_json::json; + + use super::*; + use crate::config_command::interview::providers::Acknowledge; + use crate::config_command::interview::writes::Hosts; + use crate::install::hosts::Allowance; + + const SETTINGS: &str = "/home/o/.claude/settings.json"; + const GLOBAL: &str = "/c/config.toml"; + + /// The four steps AC7 plans: the acknowledgement of `openai`, + /// `api.openai.com` into the Claude settings file, `["host","openai"]` into + /// the global file and one settings answer. + fn four_steps() -> Plan { + Plan { + acknowledgement: Some(Ack::Requested(Acknowledge { + providers: vec![Provider::OpenAi], + owner: "Ada Owner".to_owned(), + })), + hosts: Some(Hosts { + path: SETTINGS.into(), + allowance: Allowance { + document: json!({}), + bytes: Vec::new(), + read_digest: None, + added: vec!["api.openai.com"], + }, + }), + reviewers: Some(TypedPair { + name: "review.reviewers".to_owned(), + host: None, + value: Value::ReviewerList(vec!["host".to_owned(), "openai".to_owned()]), + }), + settings: vec![("roles.planner.effort".to_owned(), "max".to_owned())], + } + } + + fn recorded() -> Outcome { + Ok(Done::Acknowledgement { recorded: true }) + } + + fn failed(lines: &[&str], code: u8) -> Outcome { + Err(Render { + lines: lines.iter().map(|line| (*line).to_owned()).collect(), + code, + error: true, + }) + } + + fn text(render: &Render) -> String { + render.lines.join("\n") + } + + #[test] + fn a_step_run_after_a_failure_or_a_failure_without_the_completed_writes_is_caught() { + let plan = four_steps(); + let denied = + "not-writable: /home/o/.claude/.settings.json.7-0.tmp: Permission denied (os error 13)"; + + let outcomes = [recorded(), failed(&[denied], 3)]; + assert_eq!(next(&plan, &outcomes[..0]), Some(Step::Acknowledgement)); + assert_eq!(next(&plan, &outcomes[..1]), Some(Step::Hosts)); + assert_eq!(next(&plan, &outcomes), None); + let report = report(&plan, Path::new(GLOBAL), &outcomes); + assert!(report.lines[0].contains("not-writable"), "{report:?}"); + assert!( + report.lines[1..] + .iter() + .any(|line| line.contains("acknowledgement") + && line.contains("openai") + && line.contains("recorded")), + "{report:?}" + ); + let after = report.lines[1..].join("\n"); + assert!(after.contains("review.reviewers"), "{report:?}"); + assert!(after.contains("settings"), "{report:?}"); + assert_eq!((report.code, report.error), (3, true)); + + let outcomes = [failed(&["ledger-busy: database is locked"], 3)]; + assert_eq!(next(&plan, &outcomes), None); + let report = super::report(&plan, Path::new(GLOBAL), &outcomes); + assert!(report.lines[0].contains("ledger-busy"), "{report:?}"); + let rest = report.lines[1..].join("\n"); + assert!(!rest.contains("was recorded"), "{report:?}"); + for unwritten in ["hosts", "review.reviewers", "settings"] { + assert!(rest.contains(unwritten), "{unwritten}: {report:?}"); + } + assert_eq!((report.code, report.error), (3, true)); + + let conflict = + "config-conflict: /c/config.toml changed since it was shown; run the interview again"; + let outcomes = [ + recorded(), + Ok(Done::Hosts { note: None }), + Ok(Done::Reviewers { version: None }), + failed(&[conflict], 2), + ]; + assert_eq!(next(&plan, &outcomes), None); + let report = super::report(&plan, Path::new(GLOBAL), &outcomes); + assert_eq!(report.lines[0], conflict); + let rest = report.lines[1..].join("\n"); + assert!(rest.contains("openai"), "{report:?}"); + assert!(rest.contains("api.openai.com"), "{report:?}"); + assert!(rest.contains("review.reviewers"), "{report:?}"); + assert!(!text(&report).contains("Nothing was written"), "{report:?}"); + assert_eq!((report.code, report.error), (2, true)); + } + + /// The plan with the host step left out. + fn without_hosts() -> Plan { + Plan { + hosts: None, + ..four_steps() + } + } + + #[test] + fn a_completed_run_reported_as_a_failure_or_missing_a_write_is_caught() { + let plan = four_steps(); + let mut outcomes: Vec = Vec::new(); + let mut order = Vec::new(); + + while let Some(step) = next(&plan, &outcomes) { + order.push(step); + outcomes.push(match step { + Step::Acknowledgement => recorded(), + Step::Hosts => Ok(Done::Hosts { note: None }), + Step::Reviewers => Ok(Done::Reviewers { version: None }), + Step::Settings => Ok(Done::Settings(vec![ + "Wrote roles.planner.effort to /c/config.toml".to_owned(), + ])), + }); + } + + assert_eq!( + order, + [ + Step::Acknowledgement, + Step::Hosts, + Step::Reviewers, + Step::Settings + ] + ); + let report = report(&plan, Path::new(GLOBAL), &outcomes); + let all = text(&report); + assert!(all.contains("acknowledgement of openai"), "{all}"); + assert!(all.contains("api.openai.com"), "{all}"); + assert!(all.contains(SETTINGS), "{all}"); + assert!(all.contains("review.reviewers"), "{all}"); + assert!(all.contains("[\"host\", \"openai\"]"), "{all}"); + assert!(all.contains("Wrote roles.planner.effort"), "{all}"); + assert_eq!((report.code, report.error), (0, false)); + } + + #[test] + fn a_settings_refusal_after_provider_writes_claiming_nothing_was_written_is_caught() { + let plan = without_hosts(); + let refusal = + r#"unknown-model: roles.planner.model is "x", which no host's catalog accepts"#; + let outcomes = [ + recorded(), + Ok(Done::Reviewers { version: None }), + failed(&[refusal], 2), + ]; + + assert_eq!(next(&plan, &outcomes), None); + let report = report(&plan, Path::new(GLOBAL), &outcomes); + + assert_eq!(report.lines[0], refusal); + let rest = report.lines[1..].join("\n"); + assert!(rest.contains("acknowledgement of openai"), "{report:?}"); + assert!(rest.contains("review.reviewers"), "{report:?}"); + assert!(rest.contains("[\"host\", \"openai\"]"), "{report:?}"); + assert!(!text(&report).contains("Nothing was written"), "{report:?}"); + assert_eq!((report.code, report.error), (2, true)); + } + + #[test] + fn a_replaced_global_file_left_out_of_a_policy_step_failure_report_is_caught() { + let plan = without_hosts(); + let note = "wrote /c/config.toml, and the change stands"; + let outcomes = [ + recorded(), + failed(&["ledger-busy: database is locked", note], 3), + ]; + + assert_eq!(next(&plan, &outcomes), None); + let report = report(&plan, Path::new(GLOBAL), &outcomes); + + assert!(report.lines[0].starts_with("ledger-busy:"), "{report:?}"); + assert!(report.lines.contains(¬e.to_owned()), "{report:?}"); + let all = text(&report); + assert!(all.contains("acknowledgement of openai"), "{all}"); + assert!(!all.contains("Not written: review.reviewers"), "{all}"); + assert_eq!((report.code, report.error), (3, true)); + } +} From baf6f0ae6aec7d6405aa3ae6f240f7f2e7ce190d Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 20:17:38 -0400 Subject: [PATCH 17/27] feat(config): run the confirmed writes in order after the final yes The interview now plans before it asks, so a settings file that cannot take the hosts refuses while nothing is written, and each step runs only when it has something to change. The settings step binds to the digest the reviewer step returns, so the list survives it. --- crates/baley/src/config_command/interview.rs | 394 ++++++++++-------- .../src/config_command/interview/providers.rs | 39 +- .../src/config_command/interview/report.rs | 4 +- .../src/config_command/interview/summary.rs | 4 +- .../src/config_command/interview/writes.rs | 52 ++- crates/baley/src/config_command/reviewers.rs | 8 - crates/baley/src/config_command/set.rs | 3 - crates/baley/src/install/hosts.rs | 20 +- 8 files changed, 294 insertions(+), 230 deletions(-) diff --git a/crates/baley/src/config_command/interview.rs b/crates/baley/src/config_command/interview.rs index b97eb401..bdcd5d9c 100644 --- a/crates/baley/src/config_command/interview.rs +++ b/crates/baley/src/config_command/interview.rs @@ -1,11 +1,17 @@ //! `baley config interview` (design 0003 section 5). It gathers both settings -//! files and HEAD's copy as `show` does, asks for each role's model and effort -//! and for `escalate_on_failure` in the terminal, then asks which outside review -//! providers to use and takes a typed `yes` to the warning for each one chosen. -//! The conversation opens no store and runs no policy step itself, and its one -//! seam is the process that reads git's `user.name`. Only after the final `yes` -//! does the command open the per-user ledger to record the acknowledgement, and -//! then write the answers through one `set::apply`. +//! files, HEAD's copy and Claude Code's settings file, asks for each role's +//! model and effort and for `escalate_on_failure` in the terminal, then asks +//! which outside review providers to use and takes a typed `yes` to the warning +//! for each one chosen. A summary lists every write and every file it can +//! change, and one final `yes` confirms it. +//! +//! The conversation opens no store and writes no file, and its one seam is the +//! process that reads git's `user.name`. Only after the final `yes` do the +//! writes run, in order and each only when it has something to change: the +//! acknowledgement in the per-user ledger, the providers' API hosts in Claude +//! Code's settings file, the global `review.reviewers` list, then the settings +//! through `set::apply`. A failing step stops the run, and the report names its +//! code and the writes already done. use baley_core::policy::config_command::{SetRefusal, judge_show}; use baley_core::policy::{ @@ -17,43 +23,26 @@ use std::io::{self, BufRead, Write}; use std::path::{Path, PathBuf}; mod providers; -#[cfg_attr( - not(test), - expect( - dead_code, - reason = "the interview runs the steps once the ordered writes are wired" - ) -)] mod report; -#[cfg_attr( - not(test), - expect( - dead_code, - reason = "the interview prints the summary once the ordered writes are wired" - ) -)] mod summary; -#[cfg_attr( - not(test), - expect( - dead_code, - reason = "the interview runs the plan once the ordered writes are wired" - ) -)] mod writes; -use self::providers::{Acknowledge, Refusal, Staged}; +use self::providers::{Ready, Refusal, Staged}; +use self::report::{Done, Step}; +use self::summary::Where; +use self::writes::{Ack, Input, Plan, Refused, Stream}; use super::reviewers::{self, Selection}; use super::set; use crate::discovery::{self, Discovery, PROJECT_FILE}; use crate::folders::{Environment, Folders, Platform}; +use crate::install::hosts::{self, ClaudeSettings}; use crate::ledger::clock::SystemClock; use crate::ledger::commands::new_request_id; use crate::ledger::display::{self, Render}; use crate::ledger::open; use crate::policy_step::{self, Reads}; use crate::process::Process; -use crate::providers::{record, warning}; +use crate::providers::record; use crate::settings; /// Asks the questions on `input` and `output`, then writes the answers. @@ -105,6 +94,7 @@ fn attempt( } }; let start = begin(Schema::standard(), file, host, seen)?; + let claude = hosts::read(); let questions = questions(&start.policy); let failed = |error: io::Error| Render { @@ -123,8 +113,12 @@ fn attempt( target: &start.target, layer: start.layer, host, + global: &start.global_path, + home: &folders.home, + in_project: start.in_project, }, - &start.selection, + &start.stored, + &claude, &mut crate::process::System, input, output, @@ -136,70 +130,84 @@ fn attempt( Outcome::Unchanged => { Render::line("Nothing changed: every answer kept the value in force.", 0) } - Outcome::Send(request) => { - let recorded = match &request.acknowledgement { - Some(acknowledgement) => Some(acknowledge(&folders.home, acknowledgement)?), - None => None, - }; - let mut render = if request.pairs.is_empty() { - Render::line("", 0) - } else { - set::apply( - request.layer, - request.host, - &request.pairs, - Some(&set::Expected { - path: start.target, - digest: start.digest, - }), - ) - }; - // The recorded line leads, so a refused write still shows what was done. - match recorded { - Some(line) if request.pairs.is_empty() => render.lines = vec![line], - Some(line) => render.lines.insert(0, line), - None => {} - } - render - } + Outcome::Send(request) => execute(*request, &folders, &start), }) } -/// Records the acknowledgement in the per-user ledger unless a current one -/// covers it, and says which. A store failure is the ledger step's own line -/// with exit 3, and nothing else is written after it. -fn acknowledge(home: &Path, acknowledgement: &Acknowledge) -> Result { - let stopped = |error: StoreError| { - let mut render = record::failure(&error); - render.lines.push("Nothing else was written.".to_owned()); - render - }; +/// Runs the confirmed steps in order after the final `yes`, each only when the +/// plan holds it, and stops at the first failure. The report names that +/// failure's code, the writes already done and the steps never started. +fn execute(request: Request, folders: &Folders, start: &Start) -> Render { + let Request { layer, host, plan } = request; + let mut outcomes: Vec = Vec::new(); + // What the settings step expects of its target: the file as the interview + // showed it, until the reviewer step has written the global file. + let mut settings_digest = start.digest.clone(); + while let Some(step) = report::next(&plan, &outcomes) { + let outcome = match step { + Step::Acknowledgement => { + acknowledge(&folders.home, planned(&plan.acknowledgement).who()) + } + Step::Hosts => { + let step = planned(&plan.hosts); + hosts::write(&step.path, &step.allowance).map(|note| Done::Hosts { note }) + } + Step::Reviewers => { + let shown = set::Expected { + path: start.global_path.clone(), + digest: start.global_digest.clone(), + }; + set::apply_list(planned(&plan.reviewers), &shown).map(|written| { + if layer == FileLayer::Global { + settings_digest = Some(written.digest); + } + Done::Reviewers { + version: written.version, + } + }) + } + Step::Settings => { + let shown = set::Expected { + path: start.target.clone(), + digest: settings_digest.clone(), + }; + let render = set::apply(layer, host, &plan.settings, Some(&shown)); + if render.code == 0 { + Ok(Done::Settings(render.lines)) + } else { + Err(render) + } + } + }; + outcomes.push(outcome); + } + report::report(&plan, &start.global_path, &outcomes) +} + +/// The step a run reaches, which the plan holds because the run follows the plan. +fn planned(step: &Option) -> &T { + step.as_ref() + .expect("a step in the run is a step in the plan") +} + +/// Opens the per-user ledger, reads the `providers` stream and records the +/// acknowledgement unless a current one covers it. A store failure is the +/// step's failure, with its ledger code. +fn acknowledge(home: &Path, who: &providers::Acknowledge) -> report::Outcome { let at = SystemClock::now(); - let store = open::store(home, &at, open::options()).map_err(stopped)?; - let seen = record::read(&store).map_err(stopped)?; - let names: Vec<&str> = acknowledgement - .providers - .iter() - .map(|provider| provider.name()) - .collect(); - let version = warning::WARNING_VERSION; - match record::judge( - &seen.payloads, - &acknowledgement.providers, - &acknowledgement.owner, - &at, - ) { - None => Ok(format!( - "The acknowledgement of {} at warning version {version} was already recorded.", - names.join(", ") - )), - Some(event) => { - record::append(&store, event, seen.version, new_request_id(), &at).map_err(stopped)?; - Ok(format!( - "The acknowledgement of {} at warning version {version} was recorded.", - names.join(", ") - )) + let store = open::store(home, &at, open::options()).map_err(|error| record::failure(&error))?; + let seen = record::read(&store).map_err(|error| record::failure(&error))?; + let stream = Stream { + payloads: &seen.payloads, + at: &at, + }; + match writes::settle(who, &stream) { + Ack::Record(_, event) => { + record::append(&store, *event, seen.version, new_request_id(), &at) + .map_err(|error| record::failure(&error))?; + Ok(Done::Acknowledgement { recorded: true }) } + Ack::Reused(_) | Ack::Requested(_) => Ok(Done::Acknowledgement { recorded: false }), } } @@ -228,9 +236,24 @@ struct Start { /// The pending note, set when the target is the project file and the /// working-tree file holds changes HEAD's copy does not. note: Option, - /// The outside providers the global file stores, shown before the - /// provider question. + /// What the global file stores for the reviewer list. + stored: Stored, + /// The global file's path. + global_path: PathBuf, + /// The global file's own read, bound before the first question, which the + /// reviewer write checks. + global_digest: Option, + /// Whether the working directory is in a managed project. + in_project: bool, +} + +/// What the global file stores for `review.reviewers`. +struct Stored { + /// The outside providers, shown before the provider question. selection: Selection, + /// The names the file lists at its top level, or none when it writes no + /// such value. + names: Option>, } /// Judges the reads before the first question. A refusal is exit 2 and no @@ -272,6 +295,15 @@ fn begin( ) .map_err(|refusal| Render::refusal(refusal.to_string()))?; let layer = file.unwrap_or_else(|| default_layer(layers.global.as_ref())); + let in_project = project_file.is_some(); + let global_path = seen.global_path.clone(); + let global_digest = seen + .reads + .global + .as_ref() + .ok() + .and_then(Option::as_ref) + .map(|file| file.digest.clone()); let target = match (layer, project_file) { (FileLayer::Project, Some(path)) => path, (FileLayer::Project, None) => return Err(not_a_project()), @@ -285,7 +317,13 @@ fn begin( .map_err(|refusal| Render::refusal(refusal.to_string()))? .map(|file| file.digest); Ok(Start { - selection: reviewers::stored(layers.global.as_ref()), + stored: Stored { + selection: reviewers::stored(layers.global.as_ref()), + names: reviewers::written(layers.global.as_ref()), + }, + global_path, + global_digest, + in_project, policy: merge(schema, host, layers.global.as_ref(), layers.head.as_ref()), layer, target, @@ -471,15 +509,13 @@ fn read_answer(input: &mut impl BufRead) -> io::Result> { /// What an interview sends once the owner has typed the final `yes`. #[derive(Debug, Clone, PartialEq, Eq)] struct Request { - /// The file to write. + /// The file the settings answers go to. layer: FileLayer, /// The host section to write into, `None` for the top level. host: Option, - /// The non-blank answers as name and value, in question order. Empty when - /// only providers were confirmed. - pairs: Vec<(String, String)>, - /// The providers to record an acknowledgement for, with the owner's name. - acknowledgement: Option, + /// The confirmed steps: the acknowledgement, the hosts, the reviewer list + /// and the settings answers. + plan: Plan, } /// What the interview does with the answers. @@ -491,44 +527,8 @@ enum Outcome { Unchanged, /// The owner identity is missing, so the interview stops before its summary. Refused(Refusal), - /// Write these answers through `config set` and record this acknowledgement. - Send(Request), -} - -/// The lines that say what is about to be written and where. Providers come -/// first, so the settings block stays last. -fn summary( - target: &Path, - host: Option, - pairs: &[(String, String)], - acknowledgement: Option<&Acknowledge>, -) -> Vec { - let mut lines = Vec::new(); - if let Some(acknowledgement) = acknowledgement { - let names: Vec<&str> = acknowledgement - .providers - .iter() - .map(|provider| provider.name()) - .collect(); - lines.push(format!( - "Outside review providers confirmed: {} (warning version {})", - names.join(", "), - warning::WARNING_VERSION - )); - } - if !pairs.is_empty() { - let section = host.map_or_else(String::new, |host| format!(" [host.{}]", host.name())); - lines.push(format!( - "The interview will write to {}{section}", - target.display() - )); - lines.extend( - pairs - .iter() - .map(|(name, value)| format!(" {name}={value}")), - ); - } - lines + /// Run these steps. + Send(Box), } /// Only the word `yes` confirms, so a blank line, `y` and a closed input all @@ -539,14 +539,12 @@ fn accepts(answer: Option<&str>) -> bool { /// Lists what will be written and asks once. Whether the owner typed `yes`. fn confirm( - target: &Path, - host: Option, - pairs: &[(String, String)], - acknowledgement: Option<&Acknowledge>, + plan: &Plan, + at: &Where<'_>, input: &mut impl BufRead, output: &mut impl Write, ) -> io::Result { - for line in summary(target, host, pairs, acknowledgement) { + for line in summary::lines(plan, at) { writeln!(output, "{line}")?; } write!(output, "Write these values? Type yes to write: ")?; @@ -559,22 +557,30 @@ fn confirm( Ok(accepts(answer.as_deref())) } -/// Where the answers will be written, as the owner is told. +/// Where the writes will land, as the owner is told. struct Destination<'a> { - /// The file's path. + /// The settings file's path. target: &'a Path, /// Which file it is. layer: FileLayer, /// The host section, `None` for the top level. host: Option, + /// The global settings file, which holds the reviewer list. + global: &'a Path, + /// Baley's home, which holds the ledger. + home: &'a Path, + /// Whether the working directory is in a managed project. + in_project: bool, } -/// Asks every question, then the provider question, confirms, and decides what -/// to send. It touches no file and no store, and its one seam is `process`. +/// Asks every question, then the provider question, plans the writes, confirms +/// them and decides what to send. It touches no file and no store, and its one +/// seam is `process`. fn converse( questions: &[Question], destination: &Destination<'_>, - stored: &Selection, + stored: &Stored, + claude: &Result, process: &mut dyn Process, input: &mut impl BufRead, output: &mut impl Write, @@ -583,34 +589,49 @@ fn converse( target, layer, host, + global, + home, + in_project, } = *destination; let Collected::Answers(pairs) = ask(questions, input, output)? else { return Ok(Outcome::Declined); }; - let acknowledgement = match providers::stage(stored, process, input, output)? { + let Ready { + answer, + acknowledgement, + } = match providers::stage(&stored.selection, process, input, output)? { Staged::Declined => return Ok(Outcome::Declined), Staged::Refused(refusal) => return Ok(Outcome::Refused(refusal)), - Staged::Ready(acknowledgement) => acknowledgement, + Staged::Ready(ready) => ready, }; - if pairs.is_empty() && acknowledgement.is_none() { + // A settings file that cannot take the hosts refuses here, before the + // summary, so nothing has been written when the owner is told. + let plan = match writes::plan(&Input { + answer: &answer, + acknowledgement: acknowledgement.as_ref(), + stored: stored.names.as_deref(), + claude, + stream: None, + settings: &pairs, + }) { + Ok(plan) => plan, + Err(Refused(line)) => return Ok(Outcome::Refused(Refusal(Render::refusal(line)))), + }; + if !plan.writes() { return Ok(Outcome::Unchanged); } - if !confirm( + let at = Where { + home, + global, target, + layer, host, - &pairs, - acknowledgement.as_ref(), - input, - output, - )? { + in_project, + }; + if !confirm(&plan, &at, input, output)? { return Ok(Outcome::Declined); } - Ok(Outcome::Send(Request { - layer, - host, - pairs, - acknowledgement, - })) + Ok(Outcome::Send(Box::new(Request { layer, host, plan }))) } #[cfg(test)] @@ -726,23 +747,44 @@ mod tests { converse_with(input, host, &Selection::none(), &mut Recorded::new()) } - /// Runs the conversation with a stored selection and a scripted process. + /// Runs the conversation with a stored selection and a scripted process, + /// over a Claude settings file that is absent. fn converse_with( input: &str, host: Option, stored: &Selection, process: &mut Recorded, ) -> (Outcome, String) { + let absent = Ok(ClaudeSettings { + path: "/home/o/.claude/settings.json".into(), + current: crate::install::settings::Current { + document: serde_json::json!({}), + symbolic_link: false, + read_digest: None, + }, + }); let asked = questions(&policy(None, None, None)); let mut output = Vec::new(); + let names = (stored.layer == "global").then(|| { + std::iter::once("host".to_owned()) + .chain(stored.providers.iter().map(|p| p.name().to_owned())) + .collect() + }); let outcome = converse( &asked, &Destination { target: Path::new(GLOBAL), layer: FileLayer::Global, host, + global: Path::new(GLOBAL), + home: Path::new("/h"), + in_project: false, + }, + &Stored { + selection: stored.clone(), + names, }, - stored, + &absent, process, &mut input.as_bytes(), &mut output, @@ -772,7 +814,8 @@ mod tests { fn pairs_of(outcome: &Outcome) -> Vec<(&str, &str)> { match outcome { Outcome::Send(request) => request - .pairs + .plan + .settings .iter() .map(|(n, v)| (n.as_str(), v.as_str())) .collect(), @@ -829,15 +872,19 @@ mod tests { assert_eq!( outcome, - Outcome::Send(Request { + Outcome::Send(Box::new(Request { layer: FileLayer::Global, host: Some(Host::ClaudeCode), - pairs: vec![ - ("roles.analyzer.effort".to_owned(), "max".to_owned()), - ("escalate_on_failure".to_owned(), "true".to_owned()), - ], - acknowledgement: None, - }) + plan: Plan { + acknowledgement: None, + hosts: None, + reviewers: None, + settings: vec![ + ("roles.analyzer.effort".to_owned(), "max".to_owned()), + ("escalate_on_failure".to_owned(), "true".to_owned()), + ], + }, + })) ); assert!( output.contains("roles.analyzer.effort is high from default: "), @@ -1364,6 +1411,7 @@ mod tests { assert_eq!(start.ok().unwrap().note, None); } + use self::providers::Acknowledge; use crate::providers::fixtures::DESIGN_WARNING; use baley_core::catalog::Provider; @@ -1385,7 +1433,7 @@ mod tests { fn acknowledgement_of(outcome: &Outcome) -> Option<&Acknowledge> { match outcome { - Outcome::Send(request) => request.acknowledgement.as_ref(), + Outcome::Send(request) => request.plan.acknowledgement.as_ref().map(Ack::who), _ => None, } } diff --git a/crates/baley/src/config_command/interview/providers.rs b/crates/baley/src/config_command/interview/providers.rs index a6c55374..82d914e1 100644 --- a/crates/baley/src/config_command/interview/providers.rs +++ b/crates/baley/src/config_command/interview/providers.rs @@ -41,9 +41,19 @@ pub(super) enum Staged { Declined, /// The owner identity is missing, so the interview stops before its summary. Refused(Refusal), - /// Every warning shown was accepted. The request is `None` when no - /// provider was chosen explicitly. - Ready(Option), + /// Every warning shown was accepted. + Ready(Ready), +} + +/// The owner's answer to the provider question and, when providers were +/// chosen, what to acknowledge. +#[derive(Debug, PartialEq, Eq)] +pub(super) struct Ready { + /// What the owner answered. + pub(super) answer: Answer, + /// The confirmed providers with the owner's name, `None` when no provider + /// was chosen explicitly. + pub(super) acknowledgement: Option, } /// What the owner answered to the provider question. @@ -127,13 +137,21 @@ pub(super) fn stage( return Ok(Staged::Declined); }; match parse_answer(&answer) { - Ok(Answer::Keep) => return Ok(Staged::Ready(None)), + Ok(Answer::Keep) => { + return Ok(Staged::Ready(Ready { + answer: Answer::Keep, + acknowledgement: None, + })); + } Ok(Answer::Choose(chosen)) => break chosen, Err(line) => writeln!(output, "{line}")?, } }; if chosen.is_empty() { - return Ok(Staged::Ready(None)); + return Ok(Staged::Ready(Ready { + answer: Answer::Choose(chosen), + acknowledgement: None, + })); } for provider in &chosen { let name = provider.name(); @@ -150,10 +168,13 @@ pub(super) fn stage( } } Ok(match identity::read(process) { - Ok(owner) => Staged::Ready(Some(Acknowledge { - providers: chosen, - owner, - })), + Ok(owner) => Staged::Ready(Ready { + acknowledgement: Some(Acknowledge { + providers: chosen.clone(), + owner, + }), + answer: Answer::Choose(chosen), + }), Err(render) => Staged::Refused(Refusal(render)), }) } diff --git a/crates/baley/src/config_command/interview/report.rs b/crates/baley/src/config_command/interview/report.rs index 8405ca8c..2d5bd117 100644 --- a/crates/baley/src/config_command/interview/report.rs +++ b/crates/baley/src/config_command/interview/report.rs @@ -93,9 +93,7 @@ fn unwritten(step: Step) -> &'static str { fn written(plan: &Plan, global: &Path, done: &Done) -> Vec { match done { Done::Acknowledgement { recorded } => { - let Some(Ack::Requested(who) | Ack::Record(who, _) | Ack::Reused(who)) = - &plan.acknowledgement - else { + let Some(who) = plan.acknowledgement.as_ref().map(Ack::who) else { return Vec::new(); }; let names: Vec<&str> = who diff --git a/crates/baley/src/config_command/interview/summary.rs b/crates/baley/src/config_command/interview/summary.rs index 50dd0b63..45827ed4 100644 --- a/crates/baley/src/config_command/interview/summary.rs +++ b/crates/baley/src/config_command/interview/summary.rs @@ -104,9 +104,7 @@ fn list_text(names: &[String]) -> String { /// and last the settings block when there are settings answers. pub(super) fn lines(plan: &Plan, at: &Where<'_>) -> Vec { let mut lines = Vec::new(); - if let Some(Ack::Requested(who) | Ack::Record(who, _) | Ack::Reused(who)) = - &plan.acknowledgement - { + if let Some(who) = plan.acknowledgement.as_ref().map(Ack::who) { let names: Vec<&str> = who .providers .iter() diff --git a/crates/baley/src/config_command/interview/writes.rs b/crates/baley/src/config_command/interview/writes.rs index 7d6708f7..72d64da6 100644 --- a/crates/baley/src/config_command/interview/writes.rs +++ b/crates/baley/src/config_command/interview/writes.rs @@ -17,7 +17,7 @@ use crate::install::hosts::{self, Allowance, ClaudeSettings}; use crate::providers::record; /// What the acknowledgement step does. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub(super) enum Ack { /// The providers were confirmed, and the `providers` stream is not read /// yet, which is so until the owner's final `yes`. @@ -29,8 +29,17 @@ pub(super) enum Ack { Reused(Acknowledge), } +impl Ack { + /// The providers and the owner the acknowledgement is for. + pub(super) fn who(&self) -> &Acknowledge { + match self { + Ack::Requested(who) | Ack::Record(who, _) | Ack::Reused(who) => who, + } + } +} + /// The host step: the allowance to write into Claude Code's settings file. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub(super) struct Hosts { /// The settings file's path. pub(super) path: PathBuf, @@ -63,7 +72,7 @@ pub(super) struct Input<'a> { } /// The steps to run, each present only when it has something to change. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub(super) struct Plan { /// Step 1. pub(super) acknowledgement: Option, @@ -75,10 +84,37 @@ pub(super) struct Plan { pub(super) settings: Vec<(String, String)>, } +impl Plan { + /// Whether any step still writes something: a reused acknowledgement does + /// not. + pub(super) fn writes(&self) -> bool { + matches!( + self.acknowledgement, + Some(Ack::Requested(_) | Ack::Record(..)) + ) || self.hosts.is_some() + || self.reviewers.is_some() + || !self.settings.is_empty() + } +} + /// Why the interview stops before its summary, as the line the owner sees. #[derive(Debug, Clone, PartialEq, Eq)] pub(super) struct Refused(pub(super) String); +/// Judges the acknowledgement against the stream read after the final `yes`: +/// recorded as a new event, or reused when a stored one covers it. +pub(super) fn settle(acknowledgement: &Acknowledge, stream: &Stream<'_>) -> Ack { + match record::judge( + stream.payloads, + &acknowledgement.providers, + &acknowledgement.owner, + stream.at, + ) { + Some(event) => Ack::Record(acknowledgement.clone(), Box::new(event)), + None => Ack::Reused(acknowledgement.clone()), + } +} + /// Plans the confirmed writes. A Claude settings file that cannot be read or /// cannot take the hosts refuses the whole interview when a provider was /// chosen, so nothing has been written when the owner is told. Without a @@ -106,15 +142,7 @@ pub(super) fn plan(input: &Input<'_>) -> Result { .acknowledgement .map(|acknowledgement| match &input.stream { None => Ack::Requested(acknowledgement.clone()), - Some(stream) => match record::judge( - stream.payloads, - &acknowledgement.providers, - &acknowledgement.owner, - stream.at, - ) { - Some(event) => Ack::Record(acknowledgement.clone(), Box::new(event)), - None => Ack::Reused(acknowledgement.clone()), - }, + Some(stream) => settle(acknowledgement, stream), }); let choice = match input.answer { Answer::Choose(chosen) => Some(chosen.as_slice()), diff --git a/crates/baley/src/config_command/reviewers.rs b/crates/baley/src/config_command/reviewers.rs index 9f3a100b..ac712588 100644 --- a/crates/baley/src/config_command/reviewers.rs +++ b/crates/baley/src/config_command/reviewers.rs @@ -12,10 +12,6 @@ use baley_core::policy::{ParsedLayer, Value}; const SETTING: &str = "review.reviewers"; /// The host's own reviewer, which the list keeps as it finds it. -#[cfg_attr( - not(test), - expect(dead_code, reason = "only the list decision reads it") -)] const HOST: &str = "host"; /// The outside providers in force and the layer they came from. @@ -85,10 +81,6 @@ pub(super) fn stored(global: Option<&ParsedLayer>) -> Selection { /// `review.reviewers` as written, `None` for no value, which means the default /// `["host"]`. `chosen` is the owner's explicit choice, `None` for a blank /// answer, which writes nothing. -#[cfg_attr( - not(test), - expect(dead_code, reason = "the interview's write plan calls it") -)] pub(super) fn decide(stored: Option<&[String]>, chosen: Option<&[Provider]>) -> Option { let chosen = chosen?; let default = [HOST.to_owned()]; diff --git a/crates/baley/src/config_command/set.rs b/crates/baley/src/config_command/set.rs index db3be012..fed565ac 100644 --- a/crates/baley/src/config_command/set.rs +++ b/crates/baley/src/config_command/set.rs @@ -65,7 +65,6 @@ fn refusal(text: impl ToString) -> Render { /// What a reviewer-list write did. #[derive(Debug, Clone, PartialEq, Eq)] -#[expect(dead_code, reason = "the interview's ordered writes call it")] pub(crate) struct ListWritten { /// The SHA-256 of the bytes written, which the settings step binds to when /// it writes the same file. @@ -80,7 +79,6 @@ pub(crate) struct ListWritten { /// working directory is in a project the ledger lists. It reuses `config set`'s /// judgements and file write, but never goes through `judge_pairs`, so /// `list-not-settable` does not apply to it. -#[expect(dead_code, reason = "the interview's ordered writes call it")] pub(crate) fn apply_list(pair: &TypedPair, expected: &Expected) -> Result { let folders = Folders::resolve(Platform::current(), &Environment::read()).map_err(refusal)?; let found = found_project()?; @@ -141,7 +139,6 @@ pub(crate) fn apply_list(pair: &TypedPair, expected: &Expected) -> Result Result { let manifest = stubs::manifest(&stubs::front_doors()).expect("unique compiled front doors"); let placed_at = installed::resolve( @@ -158,13 +154,6 @@ pub(crate) fn read() -> Result { /// made first when it is missing, with the process's default mode since it is /// Claude Code's folder. The note is the sync failure's text when the file was /// replaced but its folder could not be synced. -#[cfg_attr( - not(test), - expect( - dead_code, - reason = "the interview calls it once the ordered writes are wired" - ) -)] pub(crate) fn write(path: &Path, allowance: &Allowance) -> Result, Render> { let folder = match path.parent() { Some(folder) if !folder.as_os_str().is_empty() => folder, @@ -183,13 +172,6 @@ pub(crate) fn write(path: &Path, allowance: &Allowance) -> Result /// `install-settings-conflict` with exit 2, a file or folder that could not be /// written is `not-writable` with exit 3, and a file replaced but not synced /// counts as written and keeps the sync failure's text. -#[cfg_attr( - not(test), - expect( - dead_code, - reason = "the interview reaches it through write once the ordered writes are wired" - ) -)] pub(crate) fn written(result: Result<(), Failure>) -> Result, Render> { match result { Ok(()) => Ok(None), From 1ed6dba7e0e5ea7944cf31ce0d887103d1b0c980 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 20:38:12 -0400 Subject: [PATCH 18/27] docs(design): describe the interview's provider choice, warnings and ordered writes as built Section 5 now says what the code does after the thirteen questions, the four writes after the final yes, the failure report and the new refusal. Section 6 gives the acknowledgement's fields and how it is recorded. --- docs/design/0003-configuration-and-routing.md | 91 ++++++++++++++----- 1 file changed, 67 insertions(+), 24 deletions(-) diff --git a/docs/design/0003-configuration-and-routing.md b/docs/design/0003-configuration-and-routing.md index 732f3332..dacc0e95 100644 --- a/docs/design/0003-configuration-and-routing.md +++ b/docs/design/0003-configuration-and-routing.md @@ -141,13 +141,13 @@ graph LR | CFG-R28 | The interview asks which outside providers to use, states the environment and review risks, requires typed confirmation and records the chosen providers and warning version in the ledger. A changed warning requires confirmation again. A fresh install enables no outside provider; reinstall keeps acknowledged choices. The final `yes` records the acknowledgement, writes provider API-host allowances through the installation writer, writes the global `review.reviewers` list, then runs the settings `config set`, as section 5 specifies. A setting or another entry point cannot bypass acknowledgement: no provider work order or fetch command is issued without the current acknowledgement for that provider. | Programs Claude Code starts can see environment keys, agents and subagents included. Nothing scrubs a printed key, and code goes to the chosen provider under its terms. | SYS-R10, SYS-R12 | Active | | CFG-R29 | `git.forge_provider` accepts `github`, `gitlab` and `forgejo`; the first release acts on `github` only, and choosing another value is accepted and reported as not yet supported by [0011: Landing](0011-milestones-landing-undo-pause.md). | All three forges are planned; the setting must not need to change when they arrive. | | Active | -Which policy each command runs under (CFG-R8, CFG-R9). Policy version 0 means that no recorded policy applies. Checkout admission runs before the policy step for every command that runs one: it judges the checkout against the project's other checkouts and records `checkout.seen` when the checkout is new or changed ([0001](0001-evidence-ledger.md), EVD-R17). The policy step runs before every command that appends to a project's chain from a checkout: it re-reads both files, records `policy.effective` when the merged result changed, and returns the version in force for the command's own record. A command that appends to no chain runs no step. A session server's project call applies the call's host: its write runs checkout admission and then the step for the call's host, its read runs neither, and a write whose request the ledger already holds runs neither and is answered with its original receipt. The command line's policy step records under the hostless key. The guard also merges Claude Code's host sections to judge a commit, but admits no checkout and records no `policy.effective`. Records in the per-user project `user` carry 0; the guard keeps only remembered denials from its policy, and other per-user commands build none. `purge` is the one chain-writing command that may run outside a checkout, and records 0 there. A `baley config set` outside any project, one in a project that is not in this machine's ledger, and one that changes nothing run no step. The first two give 0, since no recorded policy applies, and one that changes nothing gives the version in force. The built `baley config interview` sends its non-blank settings answers through one call to the apply function shared with `config set`, bound to the shown path and its original digest or absence. Build 3 T16 adds separate writes before that set: the per-user acknowledgement with policy version 0, Claude Code API-host allowances through the installation writer, and the global `review.reviewers` list through the whole-file settings writer. Each TOML write follows the policy-step rules above after its own write. The host settings write runs no project policy step. A declined or all-blank interview writes nothing, opens no store and records no acknowledgement. The provider extension belongs to Build 3 T16; its consumers and import boundary belong to Build 4. +Which policy each command runs under (CFG-R8, CFG-R9). Policy version 0 means that no recorded policy applies. Checkout admission runs before the policy step for every command that runs one: it judges the checkout against the project's other checkouts and records `checkout.seen` when the checkout is new or changed ([0001](0001-evidence-ledger.md), EVD-R17). The policy step runs before every command that appends to a project's chain from a checkout: it re-reads both files, records `policy.effective` when the merged result changed, and returns the version in force for the command's own record. A command that appends to no chain runs no step. A session server's project call applies the call's host: its write runs checkout admission and then the step for the call's host, its read runs neither, and a write whose request the ledger already holds runs neither and is answered with its original receipt. The command line's policy step records under the hostless key. The guard also merges Claude Code's host sections to judge a commit, but admits no checkout and records no `policy.effective`. Records in the per-user project `user` carry 0; the guard keeps only remembered denials from its policy, and other per-user commands build none. `purge` is the one chain-writing command that may run outside a checkout, and records 0 there. A `baley config set` outside any project, one in a project that is not in this machine's ledger, and one that changes nothing run no step. The first two give 0, since no recorded policy applies, and one that changes nothing gives the version in force. The `baley config interview` runs up to four writes after its final `yes`, in this order: the per-user acknowledgement with policy version 0, Claude Code API-host allowances through the settings part of the installation writer, the global `review.reviewers` list through the whole-file settings writer, and its non-blank settings answers through one call to the apply function shared with `config set`, bound to the shown path and its original digest or absence (after a list write to the same global file, to the digest that write returned). The list write and the settings write each follow the policy-step rules above after their own write. The acknowledgement and the host write run no project policy step. A declined interview, or one with nothing to change, writes nothing, opens no store and records no acknowledgement. The consumers of the acknowledgement and of `review.reviewers`, and the import boundary, belong to Build 4. | Command | Project from | Policy step | Version recorded | |---|---|---|---| | `baley init` | discovery at the repository root | yes, after `project.initialized` and checkout admission | 0 on `project.initialized` | | `baley config set` in a project | discovery | yes, after the write, when the project is in this machine's ledger | the version in force after it, or 0 when the project is not in this machine's ledger, where `baley init` records the policy | -| `baley config interview` | discovery for each TOML write, `user` for provider acknowledgement | T16: acknowledgement, host allowances, global reviewer list, then settings through the apply function shared with `config set`, bound to the shown file. No policy step for acknowledgement or host allowances; each TOML write uses the `config set` policy-step rules after its own write | 0 on the per-user acknowledgement; each TOML step records its resulting policy version when applicable | +| `baley config interview` | discovery for each TOML write, `user` for the acknowledgement | Acknowledgement, host allowances, global reviewer list, then settings through the apply function shared with `config set`, bound to the shown file. No policy step for the acknowledgement or the host allowances. Each TOML write uses the `config set` policy-step rules after its own write | 0 on the per-user acknowledgement. Each TOML step records its resulting policy version when applicable | | `anchor`, `acknowledge-restore` | discovery | yes | the version in force | | `purge` | its argument | only when run from a checkout of that project | the version in force there, otherwise 0 | | `verify --local-only`, `verify --views`, `export`, `rebuild` | their argument | no: no event is appended | none | @@ -210,7 +210,7 @@ Settings and catalog edits are owner commands. Build 4 accepts model-list return - optional `--host `, refused when the arguments are parsed if the name is unknown, exit 2, with text naming `claude-code` as the supported host. With it, each value is written in that host's `[host.]` section; - one or more `name=value` pairs, split at the first `=`. - One `config set` writes exactly one settings file and never writes a list. A value is read by its setting's type and is never parsed as TOML: `true` or `false` for a boolean, a rung name written exactly as the scale spells it, a non-empty model name, a non-empty remote name, or exactly `ask`, `refuse` or `allow`. `git.protected_branches` is a list, and `config set` writes no list: the owner edits it in `baley.toml`. A file that should hold a default has the default written as its value. + One `config set` writes exactly one settings file and never writes a list. A value is read by its setting's type and is never parsed as TOML: `true` or `false` for a boolean, a rung name written exactly as the scale spells it, a non-empty model name, a non-empty remote name, or exactly `ask`, `refuse` or `allow`. `git.protected_branches` and `review.reviewers` are lists, and `config set` writes no list: the owner edits `git.protected_branches` in `baley.toml`, and chooses the outside providers of `review.reviewers` with `baley config interview`. A file that should hold a default has the default written as its value. - **Outputs:** - **A project-file set:** the file written, each setting changed with its new value, that the change applies at the next commit, and the policy version in force. The step reads HEAD's copy of the file, so the version is usually the one already recorded until the change is committed. - **A global set:** each setting changed, the file written and the policy version. A global file applies as soon as it is written, so it never says next commit. @@ -228,7 +228,7 @@ Settings and catalog edits are owner commands. Build 4 accepts model-list return |---|---|---| | `not-a-project` | `--project` outside a project | CFG-R4 | | `unknown-setting` | The name is not in the schema | CFG-R7 | - | `list-not-settable` | The name is a list setting, `git.protected_branches`. The text says `config set` does not write lists and to edit the list in `baley.toml`. It is judged before the layer, so `--global` and `--project` get the same answer | CFG-R7 | + | `list-not-settable` | The name is a list setting, `git.protected_branches` or `review.reviewers`. The text says `config set` does not write lists. For `git.protected_branches` it says to edit the list in `baley.toml`. For `review.reviewers` it says `baley config interview` chooses the outside providers and the list can be edited by hand in `config.toml` or `baley.toml`. It is judged before the layer, so `--global` and `--project` get the same answer | CFG-R7 | | `wrong-layer` | The setting's scope excludes the requested layer | CFG-R5 | | `invalid-value` | The value is not of the setting's type, or fails its grammar | CFG-R9 | | `config-unavailable` | A file the set checks cannot be read or is invalid: the working-tree project file and its project id, then the global file and, in a project, HEAD's copy, then the file the set writes. A fault in HEAD's copy is named as HEAD's copy of the file | CFG-R9 | @@ -244,32 +244,63 @@ Settings and catalog edits are owner commands. Build 4 accepts model-list return - with neither, the global file when it holds no role value at its top level, and the project file otherwise. A role value is a `roles..model` or `roles..effort` written outside a host section. An empty `[roles]` table, or one that holds only unknown keys, holds none, and a host section's role value does not count, with or without `--host`; - a project target outside a project is refused `not-a-project` before the first question, and the interview never falls back to the global file; - optional `--host `, refused when the arguments are parsed if the name is unknown, exit 2, with text naming `claude-code` as the supported host. With it, that host's sections apply to each value in force, and every settings answer is written into `[host.]`. -- **Built settings outputs:** +- **Outputs:** - thirteen ordered questions: model then effort for each of the six roles, then `escalate_on_failure`; - with each question, the value in force and the layer it comes from. The value is merged over the global file and HEAD's copy of `baley.toml`, as `config show` merges it. An unset model shows as absent; - for a project target, the note that the project file's changes apply once committed, printed once before the first question when the working-tree file differs from HEAD's copy or is not committed. No question shows a working-tree value; - a blank answer keeps the value in force and sends nothing. An effort that is not a rung, or an `escalate_on_failure` answer other than `true` or `false`, is asked again. A model is any non-empty text. No answer clears a value, and an answer is not compared with the value in force; - - after the thirteen settings questions, the final confirmation: the built interview lists each `name=value` it will send and the file, and asks once. Only `yes` writes, through the same apply function as `config set`, bound to the shown path and the digest of that target's own read before the questions, or to its absence. T16 extends this summary and the writes as described below; - - when every settings answer is blank, no confirmation is asked, and the interview says nothing changed and exits 0; - - piped input is read as a terminal's is. Input that ends before the confirmation declines. + - after the thirteen settings questions, the provider question, the warnings and the owner identity below, then the summary and the final confirmation, which asks once. Only `yes` runs the confirmed writes, and the settings answers go through the same apply function as `config set`, bound to the shown path and the digest of that target's own read before the questions, or to its absence; + - when no step has anything to change, no confirmation is asked, and the interview says nothing changed and exits 0; + - piped input is read as a terminal's is. Input that ends at any question, at a warning or at the confirmation declines. - The built interview asks no provider or `owner.name` question. Its settings stage opens no ledger and runs no policy step of its own. Its call to `set::apply` runs the step as `config set` does (section 3). + The interview asks no `owner.name` question: the owner recorded on an acknowledgement is git's `user.name` until the approval build adds that setting. Before the final `yes` it opens no ledger and writes no file. Its settings step runs the policy step as `config set` does (section 3). - **Refusals:** - before the first question: `not-a-project`, and `config-unavailable` for the global file, the working-tree `baley.toml` and its project id, and HEAD's copy, as `config set` judges them; - - at the settings write: every refusal of `config set`, including `config-conflict` when the target path or digest changed since it was shown, a shown file disappeared, or a file appeared where none existed. If discovery finds no project for the shown project target, that is also `config-conflict`, not a request to run `baley init`. A changed path names both files: the target is now X, not the Y that was shown. A changed digest names the shown file as changed since it was shown. When discovery finds no project, the message is `no project covers this folder any more; {path} was shown; run the interview again`. All three messages say to run the interview again. The later write check covers changes after preparation. Changes to HEAD's copy or the other settings layer alone do not conflict. A model answer is not checked while the interview asks, so `unknown-model` comes from that set, naming the entries each catalog accepts, and the model is not asked again. The catalog is seeded only after the owner accepts with a model answer. In T16 this step follows the provider writes, so its refusal reports those completed writes rather than claiming nothing was written; - - a declined interview, with any answer but `yes` or with the input ended, writes nothing, opens no store, says so and exits 0. An all-blank interview also opens no store. + - before the summary, when the owner named a provider: `owner-identity-missing`, and the refusal for a Claude Code settings file that cannot take the hosts (see Owner identity and Claude Code's settings file below). Both exit 2 and write nothing; + - at the writes: the codes of the failing step, as the Confirmed writes below list them, with the report and exit codes given there; + - at the settings write: every refusal of `config set`, including `config-conflict` when the target path or digest changed since it was shown, a shown file disappeared, or a file appeared where none existed. If discovery finds no project for the shown project target, that is also `config-conflict`, not a request to run `baley init`. A changed path names both files: the target is now X, not the Y that was shown. A changed digest names the shown file as changed since it was shown. When discovery finds no project, the message is `no project covers this folder any more; {path} was shown; run the interview again`. All three messages say to run the interview again. The later write check covers changes after preparation. Changes to HEAD's copy or the other settings layer alone do not conflict. A model answer is not checked while the interview asks, so `unknown-model` comes from that set, naming the entries each catalog accepts, and the model is not asked again. The catalog is seeded only after the owner accepts with a model answer. This step follows the provider writes, so its refusal reports those completed writes rather than claiming nothing was written; + - a declined interview writes nothing, opens no store, says `Nothing was written.` and exits 0. It is declined by any answer but `yes` to a warning or to the final question, and by input that ends at any question. An interview with nothing to change also opens no store. - **Also run by:** `project start` when the global file or the project's settings are missing ([0004](0004-starting-a-project-and-changing-scope.md), PRJ-R9); the same questions, put to the owner through the host when the start runs from a session. -- **Provider selection (Build 3 T16):** extend the interview beside the settings questions with a per-user provider choice, initially none, even when role settings target a project. A blank keeps the stored selection and adds no acknowledgement request. An explicit choice is included in the final summary and written to the global `review.reviewers` list, preserving whether the host reviewer runs, through the same whole-file writer and conflict checks. This is a separate list write T16 builds, never a `config set` argument. T16 adds that setting's first setup reader and writer, so the extension does not wait for Build 4's review reader. An all-blank or declined interview writes nothing, records no acknowledgement and opens no store. -- **Provider confirmation (Build 3 T16):** show the full warning below for each explicitly selected provider and require typed confirmation with no blank default before the final `yes` that authorizes writes. Input ending or either confirmation being declined writes nothing, records no acknowledgement and opens no store. Only after both confirmations may Baley perform the ordered writes below; a ledger failure authorizes nothing. Merely editing `review.reviewers` or a tier never acknowledges a warning, and a changed warning requires confirmation again. Build 4 checks the record before issuing any review work order or list-fetch command. -- **Confirmed writes (Build 3 T16):** before asking the final question, show the selected providers, warning version, API-host allowances, reviewer list and every settings `name=value`. List every file the writes can change or create at its resolved path: the per-user ledger files for acknowledgement and any policy or catalog records, Claude Code settings chosen by the installation writer, global `config.toml`, and the settings target (only once if it is also the global file), including writer temporary files and ledger sidecars or lock files. A provider choice never changes the thirteen settings questions. After `yes`, perform these steps in order, skipping a step with no requested change: - - 1. Record `providers.acknowledged` in the per-user ledger for the confirmed providers and current warning. - 2. Write their `sandbox.network.allowedDomains` API-host allowances to Claude Code settings through the installation writer, preserving unrelated settings and hosts. - 3. Write the global `review.reviewers` list through the whole-file writer and run its applicable policy step. T16 binds this write to the file shown before confirmation and returns the digest of the bytes it wrote. - 4. Run the apply function shared with `config set` for the non-blank settings answers and its applicable policy step, still bound to the shown target. If step 3 wrote that same global file, carry its returned digest forward as the expected digest for this interview's own change, and read the file again so the reviewer list survives. Otherwise keep the original expected digest or absence. An intervening external change still refuses `config-conflict`; an unchecked reread never replaces the expected digest. - - These are separate writes, not one transaction. A failure stops at that step, reports the code, the completed writes and any file already replaced before a later policy failure, and leaves later steps unstarted. The provider stages use `ledger-busy` or `ledger-unavailable`, installation writer refusals `not-writable`, `install-ownership-conflict` and `install-settings-conflict`, and the global writer's `config-unavailable` and `config-conflict`. Re-running the interview is safe: read current settings files and show the requested changes before confirmation. Only after `yes`, open the ledger, reuse a matching current acknowledgement without another event, and apply only missing changes with the same conflict checks. A declined or all-blank rerun still opens no store. Never infer acknowledgement from a host allowance or reviewer setting. -- **Install:** use defaults without questions. A fresh install enables no outside provider; a reinstall keeps acknowledged choices. Never synthesize an acknowledgement. Outside providers stay unavailable without a current acknowledgement; the interview remains available later. +- **Provider selection:** after the thirteen settings questions the interview asks one per-user question, whether the settings target is the global file or the project file. It says that the next question chooses the outside review providers and what it accepts, then prompts `outside providers are from : ` (`crates/baley/src/config_command/interview/providers.rs:116-133`). The stored choice is the outside providers in the global file's top-level `review.reviewers`, joined with commas, or `none`. Its layer is `global` when that file writes the setting and `default` when it does not. A value in a `[host.]` section does not count, and `host` is the host's own reviewer, not an outside provider (`crates/baley/src/config_command/reviewers.rs:52-77`). The interview reads no ledger to show it. The answer is read as this table says (`crates/baley/src/config_command/interview/providers.rs:73-107`): + + | Answer | Meaning | + |---|---| + | blank | Keep the stored choice. No warning is shown, no acknowledgement is requested and no reviewer list is written | + | `openai`, `deepseek`, or both separated by commas or spaces | Choose exactly these providers. A name given twice counts once, and the providers are taken in catalog order, `openai` then `deepseek` | + | `none`, alone | Choose no outside provider | + | anything else, including `host`, a name in another case, and `none` beside a provider | Asked again, after a line that lists the accepted answers | + | input ended | The interview is declined | + +- **Provider confirmation:** each provider the owner named, in catalog order, gets the full warning below under the line `Warning for (version ):`, then a prompt to type `yes` for that provider (`crates/baley/src/config_command/interview/providers.rs:156-169`). A provider the stored choice already holds is asked again when the owner names it, so only a blank answer skips the warnings. Exactly `yes` accepts, with surrounding whitespace ignored, as at the final question (`crates/baley/src/config_command/interview.rs:536-538`). A blank, `y`, any other word or ended input declines the whole interview at once with no second chance: nothing is written, no store is opened and no acknowledgement is recorded. Editing `review.reviewers` by hand or choosing a tier never acknowledges a warning. A changed warning asks again, because a stored acknowledgement covers a provider only at the compiled warning's version and digest (section 6). Build 4 checks the record before it issues any review work order or list-fetch command. +- **Owner identity:** after the last warning is accepted, and only when the owner named a provider, the interview reads git's `user.name` with `git config --get user.name` in its own working directory, so a repository's own setting applies as git applies it. The launch has a 60 second deadline (`crates/baley/src/providers/identity.rs:15-61`, `crates/baley/src/git_process.rs:83`). The name is the owner recorded on the acknowledgement. When no name can be had, the interview refuses `owner-identity-missing` before the summary, writes nothing and exits 2. That covers a name that is unset (git exits 1), blank, spread over more than one line or holding a control character, git not installed, git's output cut short, and any other git failure. The refusal says which, and says to set the name with `git config --global user.name "Your Name"` and run the interview again. +- **Claude Code's settings file:** before its first question the interview reads the file `baley install` places, `settings.json` in `CLAUDE_CONFIG_DIR` or else in `~/.claude`, and writes nothing (`crates/baley/src/install/hosts.rs:135-150`). When the owner named a provider and the file cannot take the hosts, the interview refuses before the summary and writes nothing. The file cannot take them when its folder cannot be placed, when it cannot be read or is not a JSON object, when `sandbox`, `sandbox.network` or `sandbox.network.allowedDomains` holds a kind of value Baley cannot add hosts to, and when it is a symbolic link and a host has to be added. The file's own faults are `install-settings-conflict`, exit 2. When no provider was named the file is not consulted, so a damaged file never blocks the settings questions (`crates/baley/src/config_command/interview/writes.rs:122-157`). +- **The summary and the final question:** when at least one step has something to change, the interview prints the summary and asks once, `Write these values? Type yes to write: ` (`crates/baley/src/config_command/interview.rs:541-558`). A named provider always has something to confirm, since whether a current acknowledgement already covers it is read only after the `yes`. The summary holds, in this order (`crates/baley/src/config_command/interview/summary.rs:105-166`): + - the named providers and the warning version, for example `Outside review providers confirmed: openai (warning version 1)`; + - the API hosts to add and the settings file they go to, only for hosts the file does not hold yet; + - the new `review.reviewers` list and the global file, only when the list changes; + - every file the writes can change or create at its resolved path, a note on the temporary names, and the folders that may be created; + - the settings answers and their file, as before: `The interview will write to `, with ` [host.]` after it under `--host`, then one `name=value` line each. + + The files block lists only the files of steps that will run, and a file two steps share is listed once (`crates/baley/src/config_command/interview/summary.rs:58-94`): + + | A step that can touch it | Files listed, in the folder named | + |---|---| + | The acknowledgement, a model answer (the catalog is seeded first) and, in a project, the reviewer list or any settings answer (the policy step) | `baley.db`, `baley.db-wal`, `baley.db-shm`, `baley.db.writer` and `baley.db.maintenance` in Baley's home, and Baley's home as a folder that may be created | + | The host step | Claude Code's `settings.json`, its temporary file and its folder | + | The reviewer list, or settings answers whose target is the global file | The global `config.toml`, its temporary file and the config folder | + | Settings answers whose target is the project file | The project's `baley.toml` and its temporary file | + + A temporary file is named as the replacing writer names it, `..-.tmp` in the file's own folder (`crates/baley/src/replace.rs:169-179`). The process id and the counter are not known before the write, so the summary prints `` and `` and says they stand for numbers chosen when the file is written. +- **Nothing to change:** when no step has anything to change, the interview asks no confirmation, says `Nothing changed: every answer kept the value in force.`, opens no store and exits 0. That is the case when every settings answer is blank and the provider answer is blank, or is `none` while the stored list already names no outside provider. +- **Confirmed writes:** after `yes` the interview runs these steps in order and skips a step with nothing to change (`crates/baley/src/config_command/interview.rs:140-185`). They are separate writes, not one transaction. + + 1. **Acknowledgement.** It opens the per-user ledger in Baley's home, reads the `providers` stream of the per-user project `user`, and records `providers.acknowledged` unless the acknowledgements already stored cover every provider the owner named at the compiled warning's version and digest. Several stored events may cover them together. When one is recorded it lists every provider named in this run, not only the uncovered ones (`crates/baley/src/providers/record.rs:102-166`, `crates/baley/src/config_command/interview.rs:196-212`). The append is bound to the stream version that was read, so an acknowledgement recorded in between is refused as stale instead of being duplicated. A busy store is `ledger-busy`, any other failure to open, read or append is `ledger-unavailable`, and both exit 3. The summary cannot tell before the `yes` whether this step records or reuses, so it lists the ledger files whenever a provider was named, and the report says which happened. + 2. **API hosts.** It runs the settings-file part of the installation writer and nothing else (`crates/baley/src/install/hosts.rs:91-190`). It adds each named provider's API host, `api.openai.com` for OpenAI and `api.deepseek.com` for DeepSeek, to `sandbox.network.allowedDomains` in Claude Code's `settings.json` (`crates/baley-core/src/catalog/mod.rs:88-93`). The hosts are compiled on the provider and read from no key table. A new host goes after the entries already in the list, in catalog order. `sandbox`, `sandbox.network` and the list are made when missing, every other key and host is kept, and a host the list already holds is not added again, so a file that holds them all has no step. Hosts are only added: choosing `none` or another provider later leaves a host in place, since Baley keeps no record of which hosts it added. The write goes through the digest-checked replacement, so a file that changed since it was read is refused. The step takes no install lock, records no `install.recorded`, writes no stub, starts no `claude` command and composes none of Baley's hook or sandbox block. The folder is made with the process's default mode when it is missing, since it is Claude Code's. A changed file or a link is `install-settings-conflict`, exit 2. A folder or file that cannot be written is `not-writable`, exit 3. A file that was replaced but whose folder could not be synced counts as written, and the report adds the sync failure's text. `install-ownership-conflict`, which `baley install` raises for a hook or registration entry it does not own, cannot arise here, since the step composes neither. + 3. **Reviewer list.** It writes the global file's top-level `review.reviewers`, never a `[host.]` section. `config set` refuses this setting (section 5, `baley config set`), so this is a separate write. The new list keeps `host` as the stored top-level list holds it, present or absent, with a file that writes none counting as the default `["host"]`, and then names the providers the owner named, in catalog order. Nothing is written when the stored list, taken as a set, already equals the new one, and a blank provider answer writes nothing (`crates/baley/src/config_command/reviewers.rs:84-104`). The write is bound to the global file's path and to the digest read before the questions. It renders the whole file from the file's own bytes and keeps every other key, refuses a symbolic link, creates the config folder private when it is missing, and returns the SHA-256 of the bytes it wrote (`crates/baley/src/config_command/set.rs:82-161`). In a project that is in this machine's ledger the store is opened before the file is written, so an unsafe ledger refuses with the file as it was, and the policy step runs after the write as it does for `config set`. The refusals are `config-unavailable` and `config-conflict`, exit 2, the ledger codes of step 1, exit 3, and checkout admission's refusals as `config set` reports them. A failure after the file was replaced prints its code and then `wrote , and the change stands`. + 4. **Settings.** It runs the apply function shared with `config set` for the non-blank settings answers and its policy step, still bound to the shown target. When step 3 wrote that same global file, the digest it returned is the expected digest for this step, and the apply function reads the file again, so the reviewer list survives. Otherwise the digest read before the questions stays the expected one, or its absence. A change from outside in between still refuses `config-conflict`. The refusals are those of `config set`. +- **Failure, report and exit codes:** a failure stops at its step and no later step starts (`crates/baley/src/config_command/interview/report.rs:75-80`). The report, in order, holds the failing step's own lines with its code first, each write completed before it, `Not written:` and the steps never started (`the acknowledgement`, `the API hosts`, `review.reviewers`, `the settings`), and `Run the interview again: it finishes only what is missing.` (`crates/baley/src/config_command/interview/report.rs:153-194`). A refusal exits 2 and a write failure exits 3, as for `config set`. A run without a failure prints a line for each write and exits 0, and when the only step was an acknowledgement a current event already covered, it says `No file was changed.` +- **Running it again:** the interview is safe to rerun with the same answers. It shows the requested changes, and after `yes` the acknowledgement step finds a current event and reuses it, the host step finds every host listed, the reviewer step finds the list equal and `config set` finds every value in place, so no file changes and no acknowledgement is recorded. A reworded warning or a new version covers nothing stored, so the next confirmation records a new event. Baley never infers an acknowledgement from a host allowance or a reviewer setting. +- **Install:** use defaults without questions. `baley install` writes no `network` key and records no acknowledgement, and it keeps every `allowedDomains` host the settings file already holds. A fresh install enables no outside provider, since `review.reviewers` defaults to `["host"]`. A reinstall leaves the ledger's acknowledgements as they are, so acknowledged choices stay. Outside providers stay unavailable without a current acknowledgement, and the interview remains available later. The warning states: Baley never reads your API keys. Keys live in your environment, where every program Claude Code starts can see them, agents and subagents included. Nothing scrubs a key a command prints. Your code goes to the chosen provider under its terms. @@ -378,7 +409,19 @@ The `policy` view holds the latest `policy.effective` per project, checkout and ### providers.acknowledged (event, per-user project `user`, `providers` stream) -The per-user project `user` records the provider selection, the compiled warning version and text digest, git `user.name` as the owner identity and the acknowledgement time. T16 uses that git identity until the approval build adds the `owner.name` setting; T16 is not its first reader. The record contains no credential or environment value. Each provider has its own acknowledgement state. A current acknowledgement covers only its selected providers and warning version; choosing another provider does not revoke an existing acknowledgement. A changed warning requires a new typed acknowledgement. Work-order issuance and fetch-command output check this record, including when settings were edited outside the interview (CFG-R28). +The per-user project `user` records the providers the owner confirmed, the compiled warning's version and text digest, git `user.name` as the owner identity and the acknowledgement time. The interview uses that git identity until the approval build adds the `owner.name` setting, and it is not that setting's first reader. The record contains no credential or environment value. + +| Field | Type | Meaning | +|---|---|---| +| `providers` | list of text | Each provider confirmed in this run, once, in catalog order (`openai`, `deepseek`). One event lists every provider named in the run, including one an earlier event already covers | +| `warning_version` | integer | The version of the compiled warning the owner typed `yes` to. It is 1 | +| `warning_sha256` | text | The lower-case hex SHA-256 of the warning's UTF-8 bytes (`crates/baley/src/providers/warning.rs:11-19`) | +| `owner` | text | git `user.name`, one line of text | +| `acknowledged_at` | text | The time the interview recorded the event at | + +The event is registered at version 1 with no upcasters (`crates/baley/src/ledger/open.rs:41`, `crates/baley/src/providers/event.rs:24-26`). It is recorded by a command of kind `providers.acknowledge`, actor `owner`, policy version 0, on the stream `providers` of `user`, bound to the stream version that was read, so a second acknowledgement recorded in between is refused as stale and not duplicated; the command creates `user` when it is absent (`crates/baley/src/providers/record.rs:126-166`). No view holds it: a reader reads the `providers` stream directly, as the update events are read, so the view set is unchanged and an older binary keeps writing `user`. A stored event covers a provider only when it carries the current warning version and digest and lists the provider as text. Several events may cover the named providers together, and the owner and the time are history that decide nothing (`crates/baley/src/providers/record.rs:78-121`). + +Each provider has its own acknowledgement state. A current acknowledgement covers only its confirmed providers and warning version, and choosing another provider does not revoke an existing acknowledgement. A changed warning, in version or in wording, covers nothing stored and requires a new typed acknowledgement. Work-order issuance and fetch-command output are to check this record, including when settings were edited outside the interview (CFG-R28). That check is Build 4's. Baley has no key record, key file or key folder. Environment variable names are request metadata, never lookups performed by Baley. @@ -455,7 +498,7 @@ stateDiagram-v2 Acknowledged --> Acknowledged: work order checks provider and warning version ``` -*Figure 2. Provider risk acknowledgement, one state machine per provider. Selecting another provider leaves this provider's state unchanged. An unacknowledged provider gets no review or list-fetch work order. Install takes defaults and never supplies confirmation. Baley tracks acknowledgement, not credential state.* +*Figure 2. Provider risk acknowledgement, one state machine per provider. The interview records the transition to Acknowledged, and a stored acknowledgement of another warning version or digest covers nothing, which is the return to Unacknowledged. The loop on Acknowledged is the check at work-order and fetch-command issuance, which Build 4 adds along with the work orders. Selecting another provider leaves this provider's state unchanged. Install takes defaults and never supplies confirmation. Baley tracks acknowledgement, not credential state.* ```mermaid stateDiagram-v2 From fdcab1921b33343276657687ab456fdb0d6655fa Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 20:41:45 -0400 Subject: [PATCH 19/27] docs(design): bring design 0003's settings table, build status and citations to the code review.reviewers, the acknowledgement and the host and reviewer writes are built, so CFG-R26 and CFG-R28 say so and name Build 4's checks as unbuilt. Every file:line citation into code this phase moved points at the lines that hold what it cites. --- docs/design/0003-configuration-and-routing.md | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/design/0003-configuration-and-routing.md b/docs/design/0003-configuration-and-routing.md index dacc0e95..2589f950 100644 --- a/docs/design/0003-configuration-and-routing.md +++ b/docs/design/0003-configuration-and-routing.md @@ -730,7 +730,7 @@ The registry of built and planned settings, with the area that owns each setting | `escalate_on_failure` | bool | `false` | both | 0003 | One rung up on a retry (CFG-R16) | | `updates.auto` | bool | `false` | global | [0012](0012-host-interface.md) | Opt in to at-most-daily detached update checks (HST-R17) | | `updates.source` | HTTPS address | absent | global | [0012](0012-host-interface.md) | The development artifact source. No default implies no published release; an absent value refuses `update-source-unset`. #14 supplies the release default | -| `owner.name` | text | git `user.name` | global | [0012](0012-host-interface.md) | The approval build adds this setting and its first reader (HST-R10). Until then T16 records git `user.name` on `providers.acknowledged`; the setting is absent from the built interview and schema | +| `owner.name` | text | git `user.name` | global | [0012](0012-host-interface.md) | The approval build adds this setting and its first reader (HST-R10). Until then the interview records git `user.name` on `providers.acknowledged`; the setting is absent from the interview and the schema | | `git.protected_branches` | list of branch names | `["main", "master"]` | project | [0010](0010-guard.md) | Branches the guard protects | | `git.on_protected` | `ask`, `refuse`, `allow` | `ask` | project | [0010](0010-guard.md) | What the guard does with a commit on a protected branch | | `git.guard_hard_fail` | bool | `false` | project | [0010](0010-guard.md) | Whether a guard that cannot decide refuses instead of passing loudly | @@ -745,7 +745,7 @@ The registry of built and planned settings, with the area that owns each setting | `workflow.lint_command` | command line | absent | project | [0006](0006-execution.md) | The lint Baley runs | | `planning.phase_capacity` | integer, min 1, or absent | absent | both | [0005](0005-context-plans-and-acceptance.md) | The ceiling on a phase's size in tasks (PLN-R9) | | `planning.max_capture_bullets` | integer, min 1 | 40 | both | [0014](0014-support-families.md) | Report-only bound on active captured items | -| `review.reviewers` | list of `host`, `openai`, `deepseek` | `["host"]` | both | [0008](0008-review.md) | Which reviewers run on every triggered review; outside providers need a current recorded acknowledgement (CFG-R28) | +| `review.reviewers` | list of `host`, `openai`, `deepseek` | `["host"]` | both | [0008](0008-review.md) | Which reviewers run on every triggered review; outside providers need a current recorded acknowledgement (CFG-R28). The schema holds it with the grammar named in the type column: each name exactly `host`, `openai` or `deepseek`, in lower case. `baley config interview` is its setup reader and writer, in the global file's top level, and `config set` refuses it `list-not-settable`. Build 4 adds the review consumer | | `review.request_timeout_ms` | integer, 1 to 600000 | 540000 | both | [0008](0008-review.md) | Timeout of one outside review call | | `review.max_prompt_tokens` | integer, min 1 | 120000 | both | [0008](0008-review.md) | Bound on review prompt size | | `review.providers.

.tiers.` | model name | absent | both | [0008](0008-review.md) | An explicit override of the model supplied by the provider catalog for a tier; absent selects the owner's catalog entry for the tier, otherwise the newest accepted id tagged for that tier by `created` date, then hint order (CFG-R19). Checked against the catalog (CFG-R14) | @@ -757,7 +757,7 @@ The registry of built and planned settings, with the area that owns each setting | `review.triggers.risk_surface.waive_routing_floor` | same list | absent | project | [0009](0009-risk.md) | Surfaces whose floor the owner waives | | `debug.attempt_threshold` | integer, min 1 | 3 | both | [0014](0014-support-families.md) | Attempts before a diagnosis review is offered | -The code's schema holds a setting from the task that builds its first reader (CFG-R7). It holds `roles..model`, `roles..effort` and `escalate_on_failure`, read by route resolution. It also holds `git.remote`, read by `anchor`, `acknowledge-restore`, anchored `verify` and `doctor`, and `git.protected_branches`, `git.on_protected` and `git.guard_hard_fail`, all project-scoped. The guard's judgement in `baley-core` reads the three guard settings, and the hook builds them from the session project's policy with Claude Code's host sections, as section 11 says. The schema also holds `updates.auto`, read once at `baley serve` startup to decide whether to start the detached updater, and `updates.source`, read by manual and detached checks from the global file with no host section applied (`crates/baley-core/src/policy/schema.rs:332-346`, `crates/baley/src/update/detached.rs:9-18`, `crates/baley/src/update/command.rs:108-125`). Set them with `baley config set --global updates.auto=true` and `baley config set --global updates.source=https://`. The source grammar requires `https://` followed by at least one character and rejects whitespace, control characters, queries and fragments (`crates/baley-core/src/policy/schema.rs:188-196`). A file that names any other setting in this table gets an unknown-name diagnostic until that setting's reader exists, and the command line refuses to write it. Build 3 T16 adds the first setup reader of `review.reviewers` with the interview writer and acknowledgement; Build 4 adds its review consumer. +The code's schema holds a setting from the task that builds its first reader (CFG-R7). It holds `roles..model`, `roles..effort` and `escalate_on_failure`, read by route resolution. It also holds `git.remote`, read by `anchor`, `acknowledge-restore`, anchored `verify` and `doctor`, and `git.protected_branches`, `git.on_protected` and `git.guard_hard_fail`, all project-scoped. The guard's judgement in `baley-core` reads the three guard settings, and the hook builds them from the session project's policy with Claude Code's host sections, as section 11 says. The schema also holds `updates.auto`, read once at `baley serve` startup to decide whether to start the detached updater, and `updates.source`, read by manual and detached checks from the global file with no host section applied (`crates/baley-core/src/policy/schema.rs:342-356`, `crates/baley/src/update/detached.rs:9-18`, `crates/baley/src/update/command.rs:108-125`). Set them with `baley config set --global updates.auto=true` and `baley config set --global updates.source=https://`. The source grammar requires `https://` followed by at least one character and rejects whitespace, control characters, queries and fragments (`crates/baley-core/src/policy/schema.rs:195-203`). A file that names any other setting in this table gets an unknown-name diagnostic until that setting's reader exists, and the command line refuses to write it. The schema also holds `review.reviewers`, a list of `host`, `openai` and `deepseek` that defaults to `["host"]` and is `both`-scoped (`crates/baley-core/src/policy/schema.rs:182-184, 191-193, 357-363`), whose first reader is the interview's setup reader (`crates/baley/src/config_command/reviewers.rs:52-77`) and whose only writer is the interview (`crates/baley/src/config_command/set.rs:82-161`). Build 4 adds its review consumer. Settings removed from the schema because nothing reads them (CFG-R7): `granularity`, `workflow.research`, `workflow.plan_check`, `workflow.verifier`, `workflow.inline_plan_threshold`, `workflow.max_plan_tasks`, `workflow.max_plan_bytes`, `git.create_tag`, `git.on_land_cleanup`, `git.issue_check` (removed for good: tag and reap are the owner's choice at each merge confirmation and the tracker check always runs, [0011](0011-milestones-landing-undo-pause.md)), `review.decision_review.tier`, `review.decision_review.effort`. Removed because the design replaces them: `review.mode` (every reviewer runs, [0008](0008-review.md)), `workflow.skip_discuss` (refinement is never skipped, [0013](0013-next-action-and-progress.md)), `memory.backend` and `review.consult.*` (recall always available; consult is the diagnosis review, [0014](0014-support-families.md)), `review.key_file` (Baley handles no keys, CFG-R24 and CFG-R25) and `planning.commit_docs` (Baley writes no records into the working tree, [0001](0001-evidence-ledger.md) EVD-R18). Phase capacity is designed in [0005](0005-context-plans-and-acceptance.md) (PLN-R9), not as free numbers here. @@ -771,19 +771,19 @@ The binary parks the inherited engine for Build 9 to delete, and nothing in prod | Requirement | Status | Where | |---|---|---| -| CFG-R1 | Partly built | The policy module reads `config.toml` and `baley.toml` as TOML (`crates/baley-core/src/policy/parse.rs:428-494`, `crates/baley/src/settings.rs:22-148`), and the guard hook reads them the same way, capped (`crates/baley/src/guard_hook/policy.rs:31-59`). Only the parked engine still reads and writes the inherited JSON layers (`crates/baley/src/config/write.rs:19-24`), until Build 9 deletes it. | +| CFG-R1 | Partly built | The policy module reads `config.toml` and `baley.toml` as TOML (`crates/baley-core/src/policy/parse.rs:450-516`, `crates/baley/src/settings.rs:22-148`), and the guard hook reads them the same way, capped (`crates/baley/src/guard_hook/policy.rs:31-59`). Only the parked engine still reads and writes the inherited JSON layers (`crates/baley/src/config/write.rs:19-24`), until Build 9 deletes it. | | CFG-R2 | Built | Platform config folders and `BALEY_HOME` in `crates/baley/src/folders.rs:82-133`; the global file's path and reader in `crates/baley/src/settings.rs:14-148`. The guard hook reads the same global file from the config folder it resolves, capped at 1 MiB (`crates/baley/src/guard_hook/policy.rs:31-59`). | -| CFG-R3 | Built | Discovery takes the nearest `baley.toml` at or below the repository root (`crates/baley/src/discovery.rs:44-63`). The policy module reads and checks the project id and name in its `[project]` table and renders the file whole (`crates/baley-core/src/policy/project.rs:36-140`), and the project's settings come from the copy committed at HEAD, read through git (`crates/baley/src/committed.rs:59-73, 143-198`). A bounded variant of that reader, for the guard, runs the same three launches under its own git caller, each on the timeout the guard's budget grants from the time it has left, and keeps at most 1 MiB of each stream and 256 bytes of git's first stderr line (`crates/baley/src/committed.rs:75-137, 200-208`). The guard hook calls it at the session project's root (`crates/baley/src/guard_hook/policy.rs:31-59`), and its refusal gives git's excerpt apart so that a guard record can leave it out (`crates/baley/src/committed.rs:84-92`). `baley init` writes the file at the repository root when none exists (`crates/baley/src/init.rs:227-239`), `baley init --new-id` rewrites it with a new project id, keeping every other key, checked against the working-tree file it read (`crates/baley/src/init.rs:633-645`), and `baley config set --project` rewrites the working-tree file whole from its own bytes, keeping its `[project]` table and every other key (`crates/baley-core/src/policy/config_command/file.rs:26-62`, `crates/baley/src/config_command/set.rs:164-171`). | -| CFG-R4 | Built | The walk for the command line: `discover` judges the ancestors of the working directory that its gatherer observes, nearest first, stops at the first `.git` and never uses a file above it, and the nearest of nested entries applies alone. Only not-found is absence for either name. A `baley.toml` entry, including a directory or dangling link, or a metadata error other than not-found binds the nearest folder as the project. A `.git` entry or a metadata error other than not-found makes the folder the repository root and ends the walk (`crates/baley/src/discovery.rs:44-97`). `baley init`, `purge`, `baley config set`, `baley config show`, `baley config interview`, `anchor`, `acknowledge-restore`, anchored `verify` and `doctor` call it (`crates/baley/src/init.rs:575-579`, `crates/baley/src/ledger/commands.rs:184-195`, `crates/baley/src/ledger/commands.rs:301-341`, `crates/baley/src/config_command/set.rs:75-79`, `crates/baley/src/config_command/show.rs:28-56`, `crates/baley/src/config_command/interview.rs:39-70`). A plain project-file set outside a project is refused with `not-a-project`, as is `config show` for a project-scoped setting asked outside one (`crates/baley-core/src/policy/config_command/set.rs:214-216`, `crates/baley-core/src/policy/config_command/show.rs:100-110`). The standard schema holds four project-scoped settings, `git.remote` and the three guard settings, so `config show git.remote` outside a project is refused `not-a-project`. `anchor`, `acknowledge-restore` and anchored `verify` act on the discovered project, and a project named on the command line that differs from it, or is named when none is found, is refused (`crates/baley/src/ledger/anchor_plan.rs:35-53`, `crates/baley/src/ledger/command_plan.rs:265-309`). `config interview` refuses `not-a-project` before its first question, for `--project` and for a default that comes out as the project file, and never falls back to the global file (`crates/baley/src/config_command/interview.rs:135-144`, `crates/baley/src/config_command/interview.rs:168-172`). The server captures `CLAUDE_PROJECT_DIR` once at start (`crates/baley/src/mcp/context.rs:36-49`), and its preparation walks from that directory afresh on every project call through the same `ancestors` and `discover`. It answers `failed` with `not-a-project` when no project file is found and `project-context-invalid` when the directory cannot be walked (`crates/baley/src/mcp/prepare.rs:263-336`). `capture` and `document` reach that walk on every call whose arguments pass their checks, since an argument refusal is answered before preparation (`crates/baley/src/mcp/capture.rs:284-304`, `crates/baley/src/mcp/document.rs:180-198`). The guard hook judges its `CLAUDE_PROJECT_DIR` with the server's rules (`crates/baley/src/mcp/context.rs:154-167`) and walks from it through the same `ancestors` and `discover` on every call, binding a project only when the walk finds a managed checkout. A missing or invalid directory, or one in no project, binds none, and the hook's working directory never stands in for it: a second walk, from the working directory, finds the checkout whose `baley.toml` is protected and whose root keys remembered denials for a commit at cwd (`crates/baley/src/guard_hook/context.rs:44-134`). A commit's target is resolved from the command and supplies the branch directory (`crates/baley/src/guard_hook/decide.rs:115-131`, `crates/baley/src/guard_hook/mod.rs:106-110`); a redirected commit uses the target walk's checkout root for remembered denials (`crates/baley/src/guard_hook/record.rs:119-134`). | -| CFG-R5 | Partly built | Scopes in the schema and the scope diagnostic in the walk (`crates/baley-core/src/policy/schema.rs:155-164`, `crates/baley-core/src/policy/parse.rs:682-733`); the command-line refusal `wrong-layer` is built, judged over every pair of a `config set` before anything is read (`crates/baley-core/src/policy/config_command/set.rs:169-175`, `crates/baley-core/src/policy/config_command/set.rs:232-240`). `git.remote` and the three guard settings are project-scoped (`crates/baley-core/src/policy/schema.rs:304-331`), so the standard schema produces them: a global `config set` of `git.remote`, `git.on_protected` or `git.guard_hard_fail` refuses `wrong-layer`, a set of `git.protected_branches` in either file refuses `list-not-settable` first, and a global file holding any of the four gets the scope diagnostic. `updates.auto` and `updates.source` are global-scoped, so a project `config set` refuses `wrong-layer` and a project file holding either gets the scope diagnostic (`crates/baley-core/src/policy/schema.rs:332-346`). The guard hook's policy is built by the policy module, so the same scopes apply to it (`crates/baley/src/guard_hook/policy.rs:61-107`). The inherited `GLOBAL_ONLY` list, applied at merge (`crates/baley/src/config/mod.rs:14-18`, `crates/baley/src/config/merge.rs:59-89`), and the `repo_only` flag in `crates/baley/src/config/schema.json`, refused at write (`crates/baley/src/config/write.rs:97-101`), are the parked engine's, and both stay until Build 9. | -| CFG-R6 | Built | In the policy module: five layers in order and only the connected host's section (`crates/baley-core/src/policy/merge.rs:139-207`); the command line connects no host, so `config show` applies none unless `--host` names one, and then shows the effective values that host would run under (`crates/baley-core/src/policy/config_command/show.rs:208-223`). `config interview` takes `--host` the same way: that host's sections apply to the value in force it shows with each question (`crates/baley/src/config_command/interview.rs:158-187`). The guard hook merges the five layers through the same module with Claude Code's sections (`crates/baley/src/guard_hook/policy.rs:61-107`). The inherited merge of defaults, global and repo layers, which has no host sections (`crates/baley/src/config/merge.rs:131-177`), is reached only by the parked engine, and Build 9 deletes it. | -| CFG-R7 | Partly built | An unknown name is ignored with a diagnostic (`crates/baley-core/src/policy/parse.rs:628-661`, `crates/baley-core/src/policy/parse.rs:735-752`); the twelve settings without readers are gone from `crates/baley/src/config/schema.json`; the command-line refusal `unknown-setting` is built for `config set` and `config show` (`crates/baley-core/src/policy/config_command/set.rs:217-224`, `crates/baley-core/src/policy/config_command/show.rs:94-99`). The inherited JSON schema, which only the parked engine's merge and writer read, stays until Build 9. | -| CFG-R8 | Partly built | The `policy.effective` event, registered at version 1, and its payload built from the merged policy (`crates/baley-core/src/policy/recorded/event.rs:14-25`, `crates/baley-core/src/policy/recorded/event.rs:74-173`); the `policy` view and `PolicyProjector`, which keep the latest record per checkout and host (`crates/baley-core/src/policy/recorded/view.rs:13-124`); and the judge of whether to record (`crates/baley-core/src/policy/recorded/judge.rs:8-49`). The binary's policy step holds the recorder, which appends `policy.effective` as Baley's own `policy.record` command only when the judge finds a change (`crates/baley/src/policy_step/record.rs:18-160`), and the entry point, which reads the catalog version and returns the version in force (`crates/baley/src/policy_step/mod.rs:16-58`). The command line's store registers `policy.effective`, `checkout.seen`, `capture.recorded`, `guard.answered`, `guard.policy_recorded`, `install.recorded`, `update.checked` and `update.failed`, and the `policy`, `checkout`, `capture`, `guard`, `guard_policy` and `install` views, at view set version 8 (`crates/baley/src/ledger/open.rs:19-53`). `baley init` runs the step after `project.initialized` and checkout admission, in the order its plan gives (`crates/baley/src/init.rs:417-465`, `crates/baley/src/init.rs:698-710`). `purge` runs it only from a checkout of the project it names and records the version in force, otherwise 0 (`crates/baley-core/src/policy/recorded/purge.rs:12-25`, `crates/baley/src/ledger/commands.rs:154-211`, `crates/baley/src/ledger/commands.rs:233-264`). `config set` runs it after its write, in a project in this machine's ledger, and reports the version it returns (`crates/baley/src/config_command/set.rs:172-202`, `crates/baley/src/config_command/set.rs:334-352`). A set outside a project or in a project the ledger does not hold runs no step and reports 0, and one that changes nothing reports the version in force (`crates/baley-core/src/policy/config_command/outcome.rs:57-69`). `anchor` and `acknowledge-restore` run checkout admission and then the step after the settings read and the exact-name check of `git.remote`, and before their own command, which carries the version the step returns (`crates/baley/src/ledger/command_plan.rs:265-309`, `crates/baley/src/ledger/commands.rs:378-426`, `crates/baley/src/ledger/commands.rs:449-512`, `crates/baley/src/ledger/commands.rs:529-559`). Anchored `verify` and `doctor` run no step and append nothing. Every caller runs checkout admission between the settings read and the step. The core judges the checkout against the project's other checkouts, refusing a different remote URL as `project-id-conflict`, and plans `checkout.seen` before the step and the step before the command (`crates/baley-core/src/checkout/judge.rs:79-121`, `crates/baley-core/src/checkout/plan.rs:42-82`). The store step records `checkout.seen` as Baley's own `checkout.admit` command, or refuses a fork and records nothing, in one transaction (`crates/baley/src/checkout/admit.rs:52-183`), after the facts are gathered through git (`crates/baley/src/checkout/gather.rs:26-36`, `crates/baley/src/checkout/gather.rs:84-137`) and stripped of user information (`crates/baley/src/checkout/strip.rs:4-29`). `purge`, `config set`, `anchor` and `acknowledge-restore` call it through one entry (`crates/baley/src/checkout/mod.rs:77-101`), as does the server's write preparation, each right before its step: `purge` (`crates/baley/src/ledger/commands.rs:204-210`), `config set` (`crates/baley/src/config_command/set.rs:178-195`), `anchor` (`crates/baley/src/ledger/commands.rs:462-469`) and `acknowledge-restore` (`crates/baley/src/ledger/commands.rs:546-553`). `baley init` gathers before it opens the ledger and admits as a step of its plan (`crates/baley/src/init.rs:595-604`, `crates/baley/src/init.rs:676-687`). The server's write preparation runs checkout admission and then the step for the call's host, as Baley's own commands carrying the call's caller and the server's time, and the prepared command carries the version the step returned (`crates/baley/src/mcp/prepare.rs:338-415, 469-548`, `crates/baley/src/policy_step/mod.rs:16-58`). The step records under the host's key, while checkout admission is keyed by the policy that names no host and so carries the version stored for the checkout and no host (`crates/baley/src/mcp/prepare.rs:114-137`). A read and a request the ledger already holds run neither (`crates/baley/src/mcp/prepare.rs:263-336, 338-415`). `capture` reaches this for a request the ledger does not yet hold (`crates/baley/src/mcp/capture.rs:292-304`), `document` never does, and the guard never runs it: it builds a commit's policy with the step's `build` and records no `policy.effective` (`crates/baley/src/guard_hook/policy.rs:61-107`). The parked engine's routes persist its config inputs only (`crates/baley/src/config/reload.rs:168-181`) until Build 9. | -| CFG-R9 | Partly built | Validation and `config-unavailable` naming file, line, column and fault (`crates/baley-core/src/policy/parse.rs:259-368`, `crates/baley-core/src/policy/parse.rs:428-518`), and a file that is not a regular file or cannot be read, including a dangling link named as a link to a missing file (`crates/baley/src/settings.rs:111-135`). The same reader serves the global `config.toml`: a link there to a missing file gives `config-unavailable` to every command that reads it. The guard treats it as torn settings and asks, subject to its remembered denials (`crates/baley/src/guard_hook/policy.rs:61-107`, `crates/baley-core/src/guard/answer.rs:104-131`). The policy step's read gathers the global file and HEAD's copy of the project file and builds the policy from them, refusing with the first fault (`crates/baley/src/policy_step/read.rs:24-63`). The guard's bounded reader of HEAD's copy refuses as the policy step's reader does, with `config-unavailable` naming the working-tree file, and also when the guard's git time is spent before a launch, which then never starts (`crates/baley/src/committed.rs:106-137`). A parse, type or grammar fault in HEAD's copy is named as HEAD's copy of the file, since that copy carries the working-tree path (`crates/baley-core/src/policy/parse.rs:244-256`, `crates/baley-core/src/policy/config_command/show.rs:122-127`). A checkout or settings path that is not UTF-8 is refused with `config-unavailable` naming the path (`crates/baley-core/src/policy/recorded/event.rs:27-53`, `crates/baley-core/src/policy/recorded/event.rs:74-103`). `baley init` runs the read and build before it writes a file or opens the ledger (`crates/baley/src/init.rs:490-509`, `crates/baley/src/init.rs:580-610`), and `purge` runs them from a checkout of the project it names, refusing a managed checkout whose `baley.toml` yields no id (`crates/baley/src/ledger/commands.rs:175-231`). `config set` refuses `config-unavailable` before it writes or opens the ledger (`crates/baley/src/config_command/set.rs:390-405`, `crates/baley/src/config_command/set.rs:407-450`), and `config show` before it prints anything, opening no ledger at all (`crates/baley-core/src/policy/config_command/show.rs:86-129`). `config interview` refuses it before its first question, running `config set`'s check of the working-tree file's project id first and then the judge `config show` uses (`crates/baley/src/config_command/interview.rs:135-166`). `config interview` binds the selected layer, the confirmed target path and its own read digest, or absence, and `set::apply` checks the path and digest after preparation (`crates/baley/src/config_command/interview.rs:70-99`, `crates/baley/src/config_command/interview.rs:167-186`, `crates/baley/src/config_command/set.rs:110-119`). Its pure judges refuse a project that disappeared before pair validation and a changed path or digest after preparation with `config-conflict`. A changed path names both files, and a changed digest says the shown file changed since it was shown. When discovery finds no project, the message says no project covers this folder any more and names the shown file. All three say to run the interview again (`crates/baley/src/config_command/set.rs:81-87, 217-256`). `config-conflict` also comes from the replace's decision, which refuses a file that is not the one read (`crates/baley/src/replace.rs:58-66`), and from its whole-file replace, which writes through a temporary file and refuses a link (`crates/baley/src/replace.rs:115-165`, `crates/baley/src/replace.rs:181-214`). `config set` also refuses a link before it reads the catalog, so a set that changes nothing refuses on one (`crates/baley/src/config_command/set.rs:452-462`). `anchor`, `acknowledge-restore` and anchored `verify` refuse `config-unavailable` before any record, and refuse a managed checkout whose `baley.toml` yields no id the same way (`crates/baley/src/ledger/command_plan.rs:95-105`, `crates/baley/src/ledger/command_plan.rs:265-309`, `crates/baley/src/ledger/commands.rs:301-341`). `verify --local-only` and `verify --views` read no settings (`crates/baley/src/ledger/command_plan.rs:265-309`). `doctor` reports such a fault as a finding, leaves the discovered project checked locally and keeps checking every other project (`crates/baley/src/ledger/command_plan.rs:140-170`, `crates/baley/src/ledger/anchor_plan.rs:97-125`, `crates/baley/src/ledger/display.rs:360-363`). The server's write preparation reads and validates both files after its replay lookup and before checkout admission, and answers `failed` with `config-unavailable` and nothing recorded when either fails (`crates/baley/src/mcp/prepare.rs:127-137, 338-415`). A read validates neither file (`crates/baley/src/mcp/prepare.rs:263-336`), and a request the ledger already holds is answered before the settings are read. `capture` reaches this for a request the ledger does not yet hold (`crates/baley/src/mcp/capture.rs:292-304`), `document` never does. The guard hook reads the global and working-tree files capped at 1 MiB (`crates/baley/src/settings.rs:42-57, 137-148`), and takes a file it cannot read, one over the cap or an invalid one as torn settings, which it answers rather than refusing ([0010](0010-guard.md), GRD-R7), with no checkout admitted and nothing recorded in the project (`crates/baley/src/guard_hook/policy.rs:61-107`). The parked engine re-reads and validates its JSON layers before each write (`crates/baley/src/config/reload.rs:300-312`, `crates/baley/src/session/mod.rs:493-502`) until Build 9. | +| CFG-R3 | Built | Discovery takes the nearest `baley.toml` at or below the repository root (`crates/baley/src/discovery.rs:44-63`). The policy module reads and checks the project id and name in its `[project]` table and renders the file whole (`crates/baley-core/src/policy/project.rs:36-140`), and the project's settings come from the copy committed at HEAD, read through git (`crates/baley/src/committed.rs:59-73, 143-198`). A bounded variant of that reader, for the guard, runs the same three launches under its own git caller, each on the timeout the guard's budget grants from the time it has left, and keeps at most 1 MiB of each stream and 256 bytes of git's first stderr line (`crates/baley/src/committed.rs:75-137, 200-208`). The guard hook calls it at the session project's root (`crates/baley/src/guard_hook/policy.rs:31-59`), and its refusal gives git's excerpt apart so that a guard record can leave it out (`crates/baley/src/committed.rs:84-92`). `baley init` writes the file at the repository root when none exists (`crates/baley/src/init.rs:227-239`), `baley init --new-id` rewrites it with a new project id, keeping every other key, checked against the working-tree file it read (`crates/baley/src/init.rs:633-645`), and `baley config set --project` rewrites the working-tree file whole from its own bytes, keeping its `[project]` table and every other key (`crates/baley-core/src/policy/config_command/file.rs:26-62`, `crates/baley/src/config_command/set.rs:236-243`). | +| CFG-R4 | Built | The walk for the command line: `discover` judges the ancestors of the working directory that its gatherer observes, nearest first, stops at the first `.git` and never uses a file above it, and the nearest of nested entries applies alone. Only not-found is absence for either name. A `baley.toml` entry, including a directory or dangling link, or a metadata error other than not-found binds the nearest folder as the project. A `.git` entry or a metadata error other than not-found makes the folder the repository root and ends the walk (`crates/baley/src/discovery.rs:44-97`). `baley init`, `purge`, `baley config set`, `baley config show`, `baley config interview`, `anchor`, `acknowledge-restore`, anchored `verify` and `doctor` call it (`crates/baley/src/init.rs:575-579`, `crates/baley/src/ledger/commands.rs:184-195`, `crates/baley/src/ledger/commands.rs:301-341`, `crates/baley/src/config_command/set.rs:278-287`, `crates/baley/src/config_command/show.rs:28-56`, `crates/baley/src/config_command/interview.rs:65-96`). A plain project-file set outside a project is refused with `not-a-project`, as is `config show` for a project-scoped setting asked outside one (`crates/baley-core/src/policy/config_command/set.rs:225-227`, `crates/baley-core/src/policy/config_command/show.rs:100-110`). The standard schema holds four project-scoped settings, `git.remote` and the three guard settings, so `config show git.remote` outside a project is refused `not-a-project`. `anchor`, `acknowledge-restore` and anchored `verify` act on the discovered project, and a project named on the command line that differs from it, or is named when none is found, is refused (`crates/baley/src/ledger/anchor_plan.rs:35-53`, `crates/baley/src/ledger/command_plan.rs:265-309`). `config interview` refuses `not-a-project` before its first question, for `--project` and for a default that comes out as the project file, and never falls back to the global file (`crates/baley/src/config_command/interview.rs:265-274`, `crates/baley/src/config_command/interview.rs:307-311`). The server captures `CLAUDE_PROJECT_DIR` once at start (`crates/baley/src/mcp/context.rs:36-49`), and its preparation walks from that directory afresh on every project call through the same `ancestors` and `discover`. It answers `failed` with `not-a-project` when no project file is found and `project-context-invalid` when the directory cannot be walked (`crates/baley/src/mcp/prepare.rs:263-336`). `capture` and `document` reach that walk on every call whose arguments pass their checks, since an argument refusal is answered before preparation (`crates/baley/src/mcp/capture.rs:284-304`, `crates/baley/src/mcp/document.rs:180-198`). The guard hook judges its `CLAUDE_PROJECT_DIR` with the server's rules (`crates/baley/src/mcp/context.rs:154-167`) and walks from it through the same `ancestors` and `discover` on every call, binding a project only when the walk finds a managed checkout. A missing or invalid directory, or one in no project, binds none, and the hook's working directory never stands in for it: a second walk, from the working directory, finds the checkout whose `baley.toml` is protected and whose root keys remembered denials for a commit at cwd (`crates/baley/src/guard_hook/context.rs:44-134`). A commit's target is resolved from the command and supplies the branch directory (`crates/baley/src/guard_hook/decide.rs:115-131`, `crates/baley/src/guard_hook/mod.rs:106-110`); a redirected commit uses the target walk's checkout root for remembered denials (`crates/baley/src/guard_hook/record.rs:119-134`). | +| CFG-R5 | Partly built | Scopes in the schema and the scope diagnostic in the walk (`crates/baley-core/src/policy/schema.rs:155-164`, `crates/baley-core/src/policy/parse.rs:723-774`); the command-line refusal `wrong-layer` is built, judged over every pair of a `config set` before anything is read (`crates/baley-core/src/policy/config_command/set.rs:179-185`, `crates/baley-core/src/policy/config_command/set.rs:243-251`). `git.remote` and the three guard settings are project-scoped (`crates/baley-core/src/policy/schema.rs:314-341`), so the standard schema produces them: a global `config set` of `git.remote`, `git.on_protected` or `git.guard_hard_fail` refuses `wrong-layer`, a set of `git.protected_branches` in either file refuses `list-not-settable` first, and a global file holding any of the four gets the scope diagnostic. `updates.auto` and `updates.source` are global-scoped, so a project `config set` refuses `wrong-layer` and a project file holding either gets the scope diagnostic (`crates/baley-core/src/policy/schema.rs:342-356`). `review.reviewers` is `both`-scoped (`crates/baley-core/src/policy/schema.rs:357-363`), so a file of either layer may hold it, and a set of it refuses `list-not-settable` before the layer is judged (`crates/baley-core/src/policy/config_command/set.rs:236-242`). The guard hook's policy is built by the policy module, so the same scopes apply to it (`crates/baley/src/guard_hook/policy.rs:61-107`). The inherited `GLOBAL_ONLY` list, applied at merge (`crates/baley/src/config/mod.rs:14-18`, `crates/baley/src/config/merge.rs:59-89`), and the `repo_only` flag in `crates/baley/src/config/schema.json`, refused at write (`crates/baley/src/config/write.rs:97-101`), are the parked engine's, and both stay until Build 9. | +| CFG-R6 | Built | In the policy module: five layers in order and only the connected host's section (`crates/baley-core/src/policy/merge.rs:139-210`); the command line connects no host, so `config show` applies none unless `--host` names one, and then shows the effective values that host would run under (`crates/baley-core/src/policy/config_command/show.rs:212-227`). `config interview` takes `--host` the same way: that host's sections apply to the value in force it shows with each question (`crates/baley/src/config_command/interview.rs:288-333`). The guard hook merges the five layers through the same module with Claude Code's sections (`crates/baley/src/guard_hook/policy.rs:61-107`). The inherited merge of defaults, global and repo layers, which has no host sections (`crates/baley/src/config/merge.rs:131-177`), is reached only by the parked engine, and Build 9 deletes it. | +| CFG-R7 | Partly built | An unknown name is ignored with a diagnostic (`crates/baley-core/src/policy/parse.rs:669-702`, `crates/baley-core/src/policy/parse.rs:776-793`); the twelve settings without readers are gone from `crates/baley/src/config/schema.json`; the command-line refusal `unknown-setting` is built for `config set` and `config show` (`crates/baley-core/src/policy/config_command/set.rs:228-235`, `crates/baley-core/src/policy/config_command/show.rs:94-99`). `review.reviewers` entered the schema with its first reader, the interview's (`crates/baley/src/config_command/reviewers.rs:52-77`), and `config set` refuses it as a list (`crates/baley-core/src/policy/config_command/set.rs:236-242`). The inherited JSON schema, which only the parked engine's merge and writer read, stays until Build 9. | +| CFG-R8 | Partly built | The `policy.effective` event, registered at version 1, and its payload built from the merged policy (`crates/baley-core/src/policy/recorded/event.rs:14-25`, `crates/baley-core/src/policy/recorded/event.rs:74-173`); the `policy` view and `PolicyProjector`, which keep the latest record per checkout and host (`crates/baley-core/src/policy/recorded/view.rs:13-124`); and the judge of whether to record (`crates/baley-core/src/policy/recorded/judge.rs:8-49`). The binary's policy step holds the recorder, which appends `policy.effective` as Baley's own `policy.record` command only when the judge finds a change (`crates/baley/src/policy_step/record.rs:18-160`), and the entry point, which reads the catalog version and returns the version in force (`crates/baley/src/policy_step/mod.rs:16-58`). The command line's store registers `policy.effective`, `checkout.seen`, `capture.recorded`, `guard.answered`, `guard.policy_recorded`, `install.recorded`, `update.checked`, `update.failed` and `providers.acknowledged`, and the `policy`, `checkout`, `capture`, `guard`, `guard_policy` and `install` views, at view set version 8, which `providers.acknowledged` leaves unchanged since no view holds it (`crates/baley/src/ledger/open.rs:20-56`). `baley init` runs the step after `project.initialized` and checkout admission, in the order its plan gives (`crates/baley/src/init.rs:417-465`, `crates/baley/src/init.rs:698-710`). `purge` runs it only from a checkout of the project it names and records the version in force, otherwise 0 (`crates/baley-core/src/policy/recorded/purge.rs:12-25`, `crates/baley/src/ledger/commands.rs:154-211`, `crates/baley/src/ledger/commands.rs:233-264`). `config set` runs it after its write, in a project in this machine's ledger, and reports the version it returns (`crates/baley/src/config_command/set.rs:244-261`, `crates/baley/src/config_command/set.rs:460-489`). A set outside a project or in a project the ledger does not hold runs no step and reports 0, and one that changes nothing reports the version in force (`crates/baley-core/src/policy/config_command/outcome.rs:57-69`). `anchor` and `acknowledge-restore` run checkout admission and then the step after the settings read and the exact-name check of `git.remote`, and before their own command, which carries the version the step returns (`crates/baley/src/ledger/command_plan.rs:265-309`, `crates/baley/src/ledger/commands.rs:378-426`, `crates/baley/src/ledger/commands.rs:449-512`, `crates/baley/src/ledger/commands.rs:529-559`). Anchored `verify` and `doctor` run no step and append nothing. Every caller runs checkout admission between the settings read and the step. The core judges the checkout against the project's other checkouts, refusing a different remote URL as `project-id-conflict`, and plans `checkout.seen` before the step and the step before the command (`crates/baley-core/src/checkout/judge.rs:79-121`, `crates/baley-core/src/checkout/plan.rs:42-82`). The store step records `checkout.seen` as Baley's own `checkout.admit` command, or refuses a fork and records nothing, in one transaction (`crates/baley/src/checkout/admit.rs:52-183`), after the facts are gathered through git (`crates/baley/src/checkout/gather.rs:26-36`, `crates/baley/src/checkout/gather.rs:84-137`) and stripped of user information (`crates/baley/src/checkout/strip.rs:4-29`). `purge`, `config set`, `anchor` and `acknowledge-restore` call it through one entry (`crates/baley/src/checkout/mod.rs:77-101`), as does the server's write preparation, each right before its step: `purge` (`crates/baley/src/ledger/commands.rs:204-210`), `config set` (`crates/baley/src/config_command/set.rs:250-254, 435-458`), `anchor` (`crates/baley/src/ledger/commands.rs:462-469`) and `acknowledge-restore` (`crates/baley/src/ledger/commands.rs:546-553`). `baley init` gathers before it opens the ledger and admits as a step of its plan (`crates/baley/src/init.rs:595-604`, `crates/baley/src/init.rs:676-687`). The server's write preparation runs checkout admission and then the step for the call's host, as Baley's own commands carrying the call's caller and the server's time, and the prepared command carries the version the step returned (`crates/baley/src/mcp/prepare.rs:338-415, 469-548`, `crates/baley/src/policy_step/mod.rs:16-58`). The step records under the host's key, while checkout admission is keyed by the policy that names no host and so carries the version stored for the checkout and no host (`crates/baley/src/mcp/prepare.rs:114-137`). A read and a request the ledger already holds run neither (`crates/baley/src/mcp/prepare.rs:263-336, 338-415`). `capture` reaches this for a request the ledger does not yet hold (`crates/baley/src/mcp/capture.rs:292-304`), `document` never does, and the guard never runs it: it builds a commit's policy with the step's `build` and records no `policy.effective` (`crates/baley/src/guard_hook/policy.rs:61-107`). The parked engine's routes persist its config inputs only (`crates/baley/src/config/reload.rs:168-181`) until Build 9. | +| CFG-R9 | Partly built | Validation and `config-unavailable` naming file, line, column and fault (`crates/baley-core/src/policy/parse.rs:267-382`, `crates/baley-core/src/policy/parse.rs:450-540`), and a file that is not a regular file or cannot be read, including a dangling link named as a link to a missing file (`crates/baley/src/settings.rs:111-135`). The same reader serves the global `config.toml`: a link there to a missing file gives `config-unavailable` to every command that reads it. The guard treats it as torn settings and asks, subject to its remembered denials (`crates/baley/src/guard_hook/policy.rs:61-107`, `crates/baley-core/src/guard/answer.rs:104-131`). The policy step's read gathers the global file and HEAD's copy of the project file and builds the policy from them, refusing with the first fault (`crates/baley/src/policy_step/read.rs:24-63`). The guard's bounded reader of HEAD's copy refuses as the policy step's reader does, with `config-unavailable` naming the working-tree file, and also when the guard's git time is spent before a launch, which then never starts (`crates/baley/src/committed.rs:106-137`). A parse, type or grammar fault in HEAD's copy is named as HEAD's copy of the file, since that copy carries the working-tree path (`crates/baley-core/src/policy/parse.rs:252-264`, `crates/baley-core/src/policy/config_command/show.rs:122-127`). A checkout or settings path that is not UTF-8 is refused with `config-unavailable` naming the path (`crates/baley-core/src/policy/recorded/event.rs:27-53`, `crates/baley-core/src/policy/recorded/event.rs:74-103`). `baley init` runs the read and build before it writes a file or opens the ledger (`crates/baley/src/init.rs:490-509`, `crates/baley/src/init.rs:580-610`), and `purge` runs them from a checkout of the project it names, refusing a managed checkout whose `baley.toml` yields no id (`crates/baley/src/ledger/commands.rs:175-231`). `config set` refuses `config-unavailable` before it writes or opens the ledger (`crates/baley/src/config_command/set.rs:527-542`, `crates/baley/src/config_command/set.rs:544-587`), and `config show` before it prints anything, opening no ledger at all (`crates/baley-core/src/policy/config_command/show.rs:86-129`). `config interview` refuses it before its first question, running `config set`'s check of the working-tree file's project id first and then the judge `config show` uses (`crates/baley/src/config_command/interview.rs:265-296`). `config interview` binds the selected layer, the confirmed target path and its own read digest, or absence, and `set::apply` checks the path and digest after preparation (`crates/baley/src/config_command/interview.rs:297-332`, `crates/baley/src/config_command/interview.rs:169-180`, `crates/baley/src/config_command/set.rs:182-191`). The reviewer-list write is bound the same way to the global file's path and the digest read before the questions, and refuses `config-conflict` when the file changed since (`crates/baley/src/config_command/set.rs:142-161, 345-357`). Its pure judges refuse a project that disappeared before pair validation and a changed path or digest after preparation with `config-conflict`. A changed path names both files, and a changed digest says the shown file changed since it was shown. When discovery finds no project, the message says no project covers this folder any more and names the shown file. All three say to run the interview again (`crates/baley/src/config_command/set.rs:172-178, 318-357`). `config-conflict` also comes from the replace's decision, which refuses a file that is not the one read (`crates/baley/src/replace.rs:58-66`), and from its whole-file replace, which writes through a temporary file and refuses a link (`crates/baley/src/replace.rs:115-165`, `crates/baley/src/replace.rs:181-214`). `config set` also refuses a link before it reads the catalog, so a set that changes nothing refuses on one (`crates/baley/src/config_command/set.rs:589-599`). `anchor`, `acknowledge-restore` and anchored `verify` refuse `config-unavailable` before any record, and refuse a managed checkout whose `baley.toml` yields no id the same way (`crates/baley/src/ledger/command_plan.rs:95-105`, `crates/baley/src/ledger/command_plan.rs:265-309`, `crates/baley/src/ledger/commands.rs:301-341`). `verify --local-only` and `verify --views` read no settings (`crates/baley/src/ledger/command_plan.rs:265-309`). `doctor` reports such a fault as a finding, leaves the discovered project checked locally and keeps checking every other project (`crates/baley/src/ledger/command_plan.rs:140-170`, `crates/baley/src/ledger/anchor_plan.rs:97-125`, `crates/baley/src/ledger/display.rs:360-363`). The server's write preparation reads and validates both files after its replay lookup and before checkout admission, and answers `failed` with `config-unavailable` and nothing recorded when either fails (`crates/baley/src/mcp/prepare.rs:127-137, 338-415`). A read validates neither file (`crates/baley/src/mcp/prepare.rs:263-336`), and a request the ledger already holds is answered before the settings are read. `capture` reaches this for a request the ledger does not yet hold (`crates/baley/src/mcp/capture.rs:292-304`), `document` never does. The guard hook reads the global and working-tree files capped at 1 MiB (`crates/baley/src/settings.rs:42-57, 137-148`), and takes a file it cannot read, one over the cap or an invalid one as torn settings, which it answers rather than refusing ([0010](0010-guard.md), GRD-R7), with no checkout admitted and nothing recorded in the project (`crates/baley/src/guard_hook/policy.rs:61-107`). The parked engine re-reads and validates its JSON layers before each write (`crates/baley/src/config/reload.rs:300-312`, `crates/baley/src/session/mod.rs:493-502`) until Build 9. | | CFG-R10 | Not built | Only the parked admission compares the routing inputs (`crates/baley/src/config/reload.rs:210-221`, `crates/baley/src/execution/boundary.rs:172`). The session server answers `execution-admit` as unavailable (`crates/baley/src/mcp/operations.rs:153`) until Build 5 | -| CFG-R11 | Partly built | `baley config interview` binds its write to the shown target's path and own digest, or absence, through `set::apply` (`crates/baley/src/config_command/set.rs:36-59, 110-119`). `baley config set` writes either settings file whole (`crates/baley/src/config_command/set.rs:478-494`), `baley config interview` writes only through `set::apply`, once, after the owner types `yes` (`crates/baley/src/config_command/interview.rs:83-99`, `crates/baley/src/config_command/interview.rs:374-391`, `crates/baley/src/config_command/interview.rs:408-444`), and `baley init` writes the project file when none exists (`crates/baley/src/init.rs:227-239`) and rewrites it whole under `--new-id` (`crates/baley/src/init.rs:633-645`); the guard hook denies an agent's `Write`, `Edit` or `NotebookEdit` into the config folder, which holds `config.toml` and `keys.env`, into the home folder, and to the session project's `baley.toml` and the one of the checkout the hook's working directory is in, from any working directory and with or without a project (`crates/baley/src/protected_paths/write.rs:45-117`, `crates/baley/src/guard_hook/context.rs:77-134`, `crates/baley/src/guard_hook/decide.rs:154-204`). The placement projection that adds the placed stubs, the placed Claude Code settings and registration files and the installed executable to that list is built. It supplies `~/.local/lib/crenshawdev/baley/versions` separately as a write-only folder when placed, protecting its contents without denying reads (`crates/baley/src/host_artifacts/placement.rs:290-330`). The live guard derives the placements from `HOME` and `CLAUDE_CONFIG_DIR` at each call and passes them to its judge, so its protected list holds each placed stub, the placed Claude Code settings and registration files and the installed executable, and its write-only folder list holds the versions folder (`crates/baley/src/guard_hook/context.rs:103-174`, `crates/baley/src/host_artifacts/installed.rs:55-107`). The doctor builds its map from the same derivation and the latest install record, so it places only what the record shows (`crates/baley/src/host_doctor/mod.rs:61-120`). The sandbox and `Edit` deny-rule content that keeps agents out of the config folder and off the supplied project files is rendered by the proposal (`crates/baley/src/host_artifacts/security.rs:143-222`), including a folder-shaped write denial with no read rule for a supplied versions folder, and `baley install` merges it into the user settings file, with the other owner settings kept (`crates/baley/src/install/settings.rs:352-489`). The doctor reports the config folder and notes when it is the home folder, as `BALEY_HOME` and macOS make it (`crates/baley/src/host_doctor/store_health.rs:49-63`, `crates/baley/src/host_doctor/report.rs:185-216`). It judges the project file and the placed settings files as write-only files of the coverage judge over the settings and hook documents it is given, worded as configuration and not as proof (`crates/baley/src/host_doctor/protection.rs:48-92`). It gives the config folder no mode, owner or link judgement and never opens `keys.env`. The session server answers `config-apply` and `config-interview-apply` as unavailable and names Build 2, whose `baley config` commands replace them (`crates/baley/src/mcp/operations.rs:163-164`). The server's preparation writes no settings file and creates no project: it answers an unknown project `failed` and names `baley init` as the owner's step (`crates/baley/src/mcp/prepare.rs:263-336`), and it calls no `init`, project creation or catalog seeding (`crates/baley/src/mcp/prepare.rs:426-548`). `capture` and `document` both reach it (`crates/baley/src/mcp/capture.rs:292-304`, `crates/baley/src/mcp/document.rs:187-198`) | -| CFG-R12, CFG-R13 | Built | In the policy module: the six roles and their defaults (`crates/baley-core/src/policy/schema.rs:7-49, 275-303`), and an absent model passed as none (`crates/baley-core/src/policy/route.rs:176-279`). The parked engine's `crates/baley/src/config/roles.rs:7-14`, which its `route` served (`crates/baley/src/mcp/operations.rs:109`), keeps its `bal-*` roles until Build 9. | -| CFG-R14 | Partly built | Resolution refuses `unknown-model` against the supplied accepted names (`crates/baley-core/src/policy/route.rs:200-211`). The catalog lookup gives each host's and provider's accepted names with the catalog version (`crates/baley-core/src/catalog/lookup.rs:24-41`); resolution's production caller, which passes them, is Build 4. The write-time check is built for `config set`: `judge_models` checks each model name against the accepted names of the host `--host` names, or of at least one host with none, and never against a provider's catalog (`crates/baley-core/src/policy/config_command/set.rs:267-312`). The names are read from the `model_catalog` view after the seeding step, which runs only when a pair sets a model. Inside a project the store opens for every set, to learn whether this machine's ledger holds the project, and outside one only when a pair sets a model (`crates/baley/src/config_command/set.rs:121-148`, `crates/baley/src/config_command/set.rs:277-300`). `config interview` checks no name while it asks: a model answer is any non-empty text, taken as typed and never asked again (`crates/baley/src/config_command/interview.rs:218-227`, `crates/baley/src/config_command/interview.rs:330-352`). The answer reaches `judge_models` through the interview's call to `set::apply`, which refuses `unknown-model` there (`crates/baley/src/config_command/interview.rs:83-99`), and the catalog is seeded only after the owner accepts with a model answer. The parked engine's role resolution (`crates/baley/src/config/roles.rs:132-142`) still drops unsupported names. | +| CFG-R11 | Partly built | `baley config interview` binds its settings write to the shown target's path and own digest, or absence, through `set::apply` (`crates/baley/src/config_command/set.rs:37-60, 182-191`). `baley config set` writes either settings file whole (`crates/baley/src/config_command/set.rs:615-631`). `baley config interview` writes only after the owner types `yes` (`crates/baley/src/config_command/interview.rs:534-558`): the settings through `set::apply`, the global `review.reviewers` list through `set::apply_list`, which renders the whole global file from its own bytes (`crates/baley/src/config_command/set.rs:82-161`), and the chosen providers' API hosts into Claude Code's settings file through the settings part of the installation writer (`crates/baley/src/install/hosts.rs:157-169`), each as one step of the run (`crates/baley/src/config_command/interview.rs:140-185`). It also records the acknowledgement in the per-user ledger (`crates/baley/src/config_command/interview.rs:196-212`). `baley init` writes the project file when none exists (`crates/baley/src/init.rs:227-239`) and rewrites it whole under `--new-id` (`crates/baley/src/init.rs:633-645`); the guard hook denies an agent's `Write`, `Edit` or `NotebookEdit` into the config folder, which holds `config.toml` and `keys.env`, into the home folder, and to the session project's `baley.toml` and the one of the checkout the hook's working directory is in, from any working directory and with or without a project (`crates/baley/src/protected_paths/write.rs:45-117`, `crates/baley/src/guard_hook/context.rs:77-134`, `crates/baley/src/guard_hook/decide.rs:154-204`). The placement projection that adds the placed stubs, the placed Claude Code settings and registration files and the installed executable to that list is built. It supplies `~/.local/lib/crenshawdev/baley/versions` separately as a write-only folder when placed, protecting its contents without denying reads (`crates/baley/src/host_artifacts/placement.rs:290-330`). The live guard derives the placements from `HOME` and `CLAUDE_CONFIG_DIR` at each call and passes them to its judge, so its protected list holds each placed stub, the placed Claude Code settings and registration files and the installed executable, and its write-only folder list holds the versions folder (`crates/baley/src/guard_hook/context.rs:103-174`, `crates/baley/src/host_artifacts/installed.rs:55-107`). The doctor builds its map from the same derivation and the latest install record, so it places only what the record shows (`crates/baley/src/host_doctor/mod.rs:61-120`). The sandbox and `Edit` deny-rule content that keeps agents out of the config folder and off the supplied project files is rendered by the proposal (`crates/baley/src/host_artifacts/security.rs:144-223`), including a folder-shaped write denial with no read rule for a supplied versions folder, and `baley install` merges it into the user settings file, with the other owner settings kept (`crates/baley/src/install/settings.rs:352-489`). The doctor reports the config folder and notes when it is the home folder, as `BALEY_HOME` and macOS make it (`crates/baley/src/host_doctor/store_health.rs:49-63`, `crates/baley/src/host_doctor/report.rs:185-216`). It judges the project file and the placed settings files as write-only files of the coverage judge over the settings and hook documents it is given, worded as configuration and not as proof (`crates/baley/src/host_doctor/protection.rs:48-92`). It gives the config folder no mode, owner or link judgement and never opens `keys.env`. The session server answers `config-apply` and `config-interview-apply` as unavailable and names Build 2, whose `baley config` commands replace them (`crates/baley/src/mcp/operations.rs:163-164`). The server's preparation writes no settings file and creates no project: it answers an unknown project `failed` and names `baley init` as the owner's step (`crates/baley/src/mcp/prepare.rs:263-336`), and it calls no `init`, project creation or catalog seeding (`crates/baley/src/mcp/prepare.rs:426-548`). `capture` and `document` both reach it (`crates/baley/src/mcp/capture.rs:292-304`, `crates/baley/src/mcp/document.rs:187-198`) | +| CFG-R12, CFG-R13 | Built | In the policy module: the six roles and their defaults (`crates/baley-core/src/policy/schema.rs:7-49, 285-313`), and an absent model passed as none (`crates/baley-core/src/policy/route.rs:176-279`). The parked engine's `crates/baley/src/config/roles.rs:7-14`, which its `route` served (`crates/baley/src/mcp/operations.rs:109`), keeps its `bal-*` roles until Build 9. | +| CFG-R14 | Partly built | Resolution refuses `unknown-model` against the supplied accepted names (`crates/baley-core/src/policy/route.rs:200-211`). The catalog lookup gives each host's and provider's accepted names with the catalog version (`crates/baley-core/src/catalog/lookup.rs:24-41`); resolution's production caller, which passes them, is Build 4. The write-time check is built for `config set`: `judge_models` checks each model name against the accepted names of the host `--host` names, or of at least one host with none, and never against a provider's catalog (`crates/baley-core/src/policy/config_command/set.rs:278-323`). The names are read from the `model_catalog` view after the seeding step, which runs only when a pair sets a model. Inside a project the store opens for every set, to learn whether this machine's ledger holds the project, and outside one only when a pair sets a model (`crates/baley/src/config_command/set.rs:193-220`, `crates/baley/src/config_command/set.rs:378-401`). `config interview` checks no name while it asks: a model answer is any non-empty text, taken as typed and never asked again (`crates/baley/src/config_command/interview.rs:364-373`, `crates/baley/src/config_command/interview.rs:476-498`). The answer reaches `judge_models` through the interview's call to `set::apply`, which refuses `unknown-model` there (`crates/baley/src/config_command/interview.rs:169-180`), and the catalog is seeded only after the owner accepts with a model answer. The parked engine's role resolution (`crates/baley/src/config/roles.rs:132-142`) still drops unsupported names. | | CFG-R15 | Partly built | The five rungs and the rung map argument of `resolve_route`, whose mapped value the route carries as `host_effort` (`crates/baley-core/src/policy/schema.rs:52-95`, `crates/baley-core/src/policy/route.rs:25-40`, `crates/baley-core/src/policy/route.rs:219`); Claude Code's identity map, the per-model support table and the separate requested and effective records are Build 4's first dispatch task. The parked engine's resolution maps a rung to one of 30 agent names (`crates/baley/src/config/roles.rs:15-60`) until Build 9. | | CFG-R16 | Partly built | The escalation rule in `resolve_route` (`crates/baley-core/src/policy/route.rs:212-218`); Build 4 supplies the attempt. The parked engine's resolution (`crates/baley/src/config/roles.rs:127-131`) is called with attempt `None` everywhere (#69). | | CFG-R17 | Partly built | The route type carries every field of section 6 (`crates/baley-core/src/policy/route.rs:72-99`); a work order carrying it is Build 4. The parked engine's resolution (`crates/baley/src/config/roles.rs:143-180`) has no policy or catalog version. | @@ -794,9 +794,9 @@ The binary parks the inherited engine for Build 9 to delete, and nothing in prod | CFG-R23 | Partly built | Every catalog event records the version before it, read in its own transaction: a seed and an owner change (`crates/baley/src/models.rs:102-116`, `crates/baley/src/models.rs:223-232`), and a detection (`crates/baley/src/detection/record.rs:77-89`), which reports the version its own command committed (`crates/baley/src/detection/record.rs:98-105`). A body cut short at the 4 MiB bound records `incomplete` with that version and removes nothing (`crates/baley-core/src/catalog/detection/classify.rs:102-104`, `crates/baley-core/src/catalog/detection/event.rs:67-72`). The view keeps the version as the sequence of the latest event that changed an accepted name (`crates/baley-core/src/catalog/view.rs:427-444`). The lookup returns it beside the names, which is what a route's `catalog_version` takes (`crates/baley-core/src/policy/route.rs:16-23`, `crates/baley-core/src/policy/route.rs:276`). Routes are recorded in Build 4. | | CFG-R24 | Not built | The `keys.env` reader is still built in `crates/baley/src/keys.rs`. Its removal belongs to Build 4 (#25). | | CFG-R25 | Not built | `baley exec --key` still reads and injects a key (`crates/baley/src/exec.rs`). The parked review engine also reads environment values and `providers.env` (`crates/baley/src/review/provider/credentials.rs`). Removal of the built key reader and `baley exec --key` belongs to Build 4 (#25). | -| CFG-R26 | Not built | The built key type, redactor and sensitive HTTP header handling still receive values (`crates/baley/src/keys.rs`, `crates/baley/src/exec.rs`, `crates/baley/src/detection/lister.rs`). They do not implement the no-key boundary. Their removal belongs to Build 4 (#25). Build 4 owns the unbuilt work-order records; Build 3 T16 owns the unbuilt acknowledgement records. | +| CFG-R26 | Not built | The built key type, redactor and sensitive HTTP header handling still receive values (`crates/baley/src/keys.rs`, `crates/baley/src/exec.rs`, `crates/baley/src/detection/lister.rs`). They do not implement the no-key boundary. Their removal belongs to Build 4 (#25). Build 4 owns the unbuilt work-order records. The acknowledgement record is built and holds no credential or environment value (`crates/baley/src/providers/event.rs:34-54`). | | CFG-R27 | Not built | The HTTPS lister and trigger key-name lookup still run (`crates/baley/src/detection/lister.rs`, `crates/baley/src/detection/trigger.rs`). Their removal belongs to Build 4 (#25). The pure catalog detection code stays. | -| CFG-R28 | Not built | The interview only asks role and escalation questions (`crates/baley/src/config_command/interview.rs`). Provider selection, the warning, typed confirmation and ledger acknowledgement are absent. Build 3 T16 owns that extension, the installation-writer API-host allowances and the separate global writer of `review.reviewers`, in the ordered confirmation flow of section 5. Build 4 owns acknowledgement enforcement at review and fetch issuance. | +| CFG-R28 | Partly built | After its thirteen questions the interview asks which outside providers to use (`crates/baley/src/config_command/interview/providers.rs:116-133`), shows the warning for each provider the owner names and takes a typed `yes` (`crates/baley/src/config_command/interview/providers.rs:156-169`), and reads git `user.name` as the owner (`crates/baley/src/providers/identity.rs:22-26`). It prints a summary naming every file the writes can change or create (`crates/baley/src/config_command/interview/summary.rs:58-166`). After the final `yes` it records the acknowledgement or reuses a current one (`crates/baley/src/providers/record.rs:102-166`, `crates/baley/src/config_command/interview.rs:196-212`), adds the providers' API hosts to Claude Code's settings through the installation writer (`crates/baley/src/install/hosts.rs:91-190`), writes the global `review.reviewers` list (`crates/baley/src/config_command/reviewers.rs:84-104`, `crates/baley/src/config_command/set.rs:82-161`), and then runs the settings through `set::apply` (`crates/baley/src/config_command/interview.rs:140-185`). A reworded warning asks again, since a stored acknowledgement covers a provider only at the compiled warning's version and digest (`crates/baley/src/providers/record.rs:78-121`). A fresh install enables no outside provider, and install records no acknowledgement. Build 4 owns the unbuilt part: the acknowledgement check at review work-order and list-fetch issuance, and the review consumer of `review.reviewers`. | | CFG-R29 | Not built | The TOML schema in `crates/baley-core/src/policy/schema.rs` has no `git.forge_provider`. Only the inherited JSON schema lists the values (`crates/baley/src/config/schema.json:160-169`), and only the parked landing reads them (`crates/baley/src/landing/forge.rs:35-57`, `crates/baley/src/landing/report.rs:130-166`), with no not-yet-supported report. The session server answers `land-read` and the landing apply spellings as unavailable (`crates/baley/src/mcp/operations.rs:96, 173-184`) until Build 6 | ## 12. Open questions From fc55e0ee4946056e8ada3e1ac2a60893fefed754 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 20:42:19 -0400 Subject: [PATCH 20/27] docs(design): say in design 0008 that the provider warning, choice and acknowledgement are built The warning sentence now matches design 0003 word for word. REV-R3 and REV-R20 name what is built with its code and leave admission and the raw-response boundary to Build 4. --- docs/design/0008-review.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/design/0008-review.md b/docs/design/0008-review.md index 8e185506..7ddb2e72 100644 --- a/docs/design/0008-review.md +++ b/docs/design/0008-review.md @@ -81,7 +81,7 @@ In the component view of [0002](0002-system-design.md) (Figure 4) this area is o | REV-R22 | When a round is delivered for adjudication, Baley looks up every returned finding's fingerprint in the project's `dismissal` view and hands each match to the host session with the finding: the earlier ruling (review, round, finding), its reason and the date it was ruled. A finding matches when its fingerprint has a standing dismissal in the same project; a merged finding matches when any finding merged into it does, and carries every match. Baley looks up again when it records `review.adjudication` and records the matches there; the recorded matches are the ones the owner sees and the ruling cites. The model never decides a match. A match never suppresses a finding: the finding is checked against the code as it is now like any other, an earlier dismissal is never a reason to drop it, and a survivor goes to the owner with the earlier dismissal beside it. Reviewers are never given earlier rulings. For round 2, Baley selects the findings the owner ruled `fix` and places their ids and finding content in the work order as findings to re-check, without the rulings, reasons or earlier dismissals. | The code may have changed since the dismissal, so the owner decides again, with the memory of what was decided and why. | REV-R7, REV-R21 | Active | | REV-R23 | The owner rules on a recurring finding like any survivor, and the ruling decides the earlier dismissal: `dismiss` with a reason confirms it, `fix` or `track` reverses it. Baley copies the recorded matches into the `review.adjudicated` record as its citation; the owner does not supply them. A ruling covers the finding's fingerprint and those of the findings merged into it. Every `dismiss` ruling becomes the standing dismissal of each fingerprint it covers, replacing an earlier one; every `fix` or `track` ruling ends the standing dismissal of each fingerprint it covers. | Each decision on a returning fault is on the record with the one it confirms or overturns, and the lookup always finds the latest. | REV-R8, REV-R22 | Active | -The provider warning and typed acknowledgement are specified in [0003](0003-configuration-and-routing.md), CFG-R28. Baley never reads API keys. Every program Claude Code starts can see keys in the environment, agents and subagents included. Nothing scrubs a key a command prints. Review material goes to the chosen provider under its terms. +The provider warning and typed acknowledgement are specified in [0003](0003-configuration-and-routing.md), CFG-R28. The interview shows the warning for each provider the owner names, word for word: Baley never reads your API keys. Keys live in your environment, where every program Claude Code starts can see them, agents and subagents included. Nothing scrubs a key a command prints. Your code goes to the chosen provider under its terms. ## 4. Roles and actors @@ -431,7 +431,7 @@ sequenceDiagram | Setting | Type | Default | Scope | Owner | Effect | |---|---|---|---|---|---| -| `review.reviewers` | list of `host`, `openai`, `deepseek` | `["host"]` | both | 0008 | Which reviewers run on every triggered review (REV-R3) | +| `review.reviewers` | list of `host`, `openai`, `deepseek` | `["host"]` | both | 0008 | Which reviewers run on every triggered review (REV-R3). `baley config interview` writes it, in the global file's top level, and `config set` refuses it `list-not-settable`. The schema holds it, and Build 4 adds the review consumer | | `review.providers.

.tiers.` | model name | absent | both | 0008 | An explicit tier-model override; absent selects the owner's catalog entry for the tier, otherwise the newest accepted id tagged for it by `created` date, then hint order (CFG-R19). Checked against the catalog (CFG-R14) | | `review.triggers..gate` | `off`, `advisory`, `deferred`, `blocking`, `adjudicated` | plan `advisory`, diff `off`, risk_surface `blocking` | both | 0008 | What each review holds (REV-R2); the plan gate is also the checker's switch (PLN-R16) | | `review.triggers.plan.recheck` | `full`, `diff` | `full` | both | [0005](0005-context-plans-and-acceptance.md) | The reading scope of round 2 for a plan check or plan review: `full` reads the whole revised plan; `diff` reads every addition, modification and deletion with before-and-after context. Both scopes check closure: every first-round blocker for the checker (PLN-R16), and every finding the owner ruled `fix` for a plan review (REV-R10). Both may report new defects. It does not control `diff` or `risk_surface` reviews. | @@ -460,7 +460,7 @@ The binary parks the inherited engine for Build 9 to delete, and nothing in prod |---|---|---| | REV-R1 | Not built | Only the parked engine admits a review with exactly one trigger or one kind (`crates/baley/src/review/policy.rs:5-11`, `crates/baley/src/review_service.rs:188-201`). The session server answers `review-admit` as unavailable (`crates/baley/src/mcp/operations.rs:158`) until Build 4 | | REV-R2 | Not built | Only the parked engine maps a gate to an action (`crates/baley/src/review/policy.rs:83-106`), and its blocking and adjudicated gates wait for a settlement nothing writes (`crates/baley/src/review_service.rs:442`). The session server answers `review-admit` as unavailable (`crates/baley/src/mcp/operations.rs:158`) until Build 4 | -| REV-R3 | Not built | Only the parked engine selects reviewers by a mode, with its fallback fixed to `claude-subagent` (`crates/baley/src/review_service.rs:607-611`), and walks them in turn (`crates/baley/src/review/selection.rs:52-90`). The session server answers `review-next` and `review-select` as unavailable (`crates/baley/src/mcp/operations.rs:117, 126`) until Build 4. Build 4 also owns catalog tier selection and its recorded date and hint-order inputs. Outside work orders depend on Build 3 T16's provider choice and ledger acknowledgement (CFG-R28) | +| REV-R3 | Not built | Only the parked engine selects reviewers by a mode, with its fallback fixed to `claude-subagent` (`crates/baley/src/review_service.rs:607-611`), and walks them in turn (`crates/baley/src/review/selection.rs:52-90`). The session server answers `review-next` and `review-select` as unavailable (`crates/baley/src/mcp/operations.rs:117, 126`) until Build 4. Build 4 also owns catalog tier selection and its recorded date and hint-order inputs. The provider choice and the ledger acknowledgement that outside work orders depend on are built: `baley config interview` asks which providers to use and writes the global `review.reviewers` list (`crates/baley/src/config_command/reviewers.rs:84-104`, `crates/baley/src/config_command/set.rs:82-161`), and it records the acknowledgement (`crates/baley/src/providers/record.rs:102-166`, `crates/baley/src/config_command/interview.rs:196-212`). Admission's use of them (CFG-R28) is Build 4's | | REV-R4 | Not built | Only the parked engine calls providers itself: it runs the delivery (`crates/baley/src/review/provider/delivery.rs:78-112`), sends the request (`crates/baley/src/review/provider/transport.rs:114-142`) and bounds the prompt (`crates/baley/src/review/provider/payload.rs:195-210`). The session server answers `review-next` and `review-admit` as unavailable (`crates/baley/src/mcp/operations.rs:117, 158`) until Build 4 | | REV-R5 | Not built | Only the parked engine retains material once at admission (`crates/baley/src/review/material.rs:442-711`, `crates/baley/src/review/admission.rs:22-52`). The session server answers `review-admit` and `review-material-append` as unavailable (`crates/baley/src/mcp/operations.rs:158, 161`) until Build 4 | | REV-R6 | Not built | Only the parked engine validates returned findings (`crates/baley/src/review/contract.rs:80-172`) and decides the return (`crates/baley/src/review/returns.rs:266-402`). The session server answers `review-return` as unavailable (`crates/baley/src/mcp/operations.rs:160`) until Build 4 | @@ -471,7 +471,7 @@ The binary parks the inherited engine for Build 9 to delete, and nothing in prod | REV-R14 | Not built | Only the parked engine runs an on-demand review: minimalism (`crates/baley/src/review/specialist.rs:13-46`) and decision lines (`crates/baley/src/review/selection.rs:329-381`), and the diagnosis target it builds has no caller (`crates/baley/src/review_service.rs:954-974`). The specialist front doors still render through `baley review-instructions --alias` (`crates/baley/src/instruction_surfaces.rs:30-32`), while the specialist review itself is parked. The session server answers `review-select` and `review-admit` as unavailable (`crates/baley/src/mcp/operations.rs:126, 158`) until Build 4 | | REV-R15, REV-R16 | Not built | The tracker check of the parked landing only reads (`crates/baley/src/landing/report.rs:1`) | | REV-R19 | Not built | Only the parked engine records the usage of each reviewer (`crates/baley/src/review/provider/usage.rs:8-9, 116-161`, `crates/baley/src/review/provider/records.rs:34-77`). The session server answers `review-observation` and `review-return` as unavailable (`crates/baley/src/mcp/operations.rs:159-160`) until Build 4 | -| REV-R20 | Not built | The parked engine has a key redactor (`crates/baley/src/review/provider/payload.rs:25-113`). Build 4 owns the unbuilt raw-response boundary and acknowledgement checks; Build 3 T16 owns the unbuilt warning and acknowledgement. Removing the built key reader and `baley exec --key` belongs to Build 4 (#25) under ADR 0039. | +| REV-R20 | Partly built | The warning and the acknowledgement are built: the compiled warning (`crates/baley/src/providers/warning.rs:11-19`), shown for each provider the owner names with a typed `yes` (`crates/baley/src/config_command/interview/providers.rs:156-169`), and recorded as `providers.acknowledged`, which holds no credential or environment value (`crates/baley/src/providers/event.rs:34-54`, `crates/baley/src/providers/record.rs:102-166`). The parked engine has a key redactor (`crates/baley/src/review/provider/payload.rs:25-113`). Build 4 owns the unbuilt raw-response boundary and acknowledgement checks. Removing the built key reader and `baley exec --key` belongs to Build 4 (#25) under ADR 0039. | | REV-R21, REV-R22, REV-R23 | Not built | The parked review contract returns a finding with no fingerprint (`crates/baley/src/review/contract.rs:155-170`), no ruling is recorded and no `dismissal` view exists | ## 12. Open questions From 41674edb20d0899af6ae242a4aa6fcb18305fa1e Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 20:43:47 -0400 Subject: [PATCH 21/27] docs(design): describe the interview's provider setup in design 0012 as built The setup paragraph and Figure 7 show the warning, the typed yes and the four ordered writes. The host step takes no install lock and records no install.recorded, so the record's settings digest predates the file and T17's doctor must not read that as drift. --- docs/design/0012-host-interface.md | 47 +++++++++++++++++------------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/docs/design/0012-host-interface.md b/docs/design/0012-host-interface.md index 7a5ac559..0cb885df 100644 --- a/docs/design/0012-host-interface.md +++ b/docs/design/0012-host-interface.md @@ -60,7 +60,7 @@ In the component view of [0002](0002-system-design.md) (Figure 4) this area is t | HST-R7 | The project comes from the session's `CLAUDE_PROJECT_DIR`, which the server reads once when it starts (CFG-R4). Every project call first judges its own arguments, and a refusal there is answered before anything is prepared. The call then discovers its project afresh from that directory, and no call argument, working directory or project field selects it. The call needs the project in this machine's ledger. The server records the working directory it started in beside the project, and a directory that differs from the project is valid. A read appends nothing. After the project check, a write runs, in order, the replay lookup, the settings read and validation, checkout admission, and the policy step under the host's key, then its own command with the version the step returned. Checkout admission, the step and the command are recorded as the actor `baley`, with the call's caller and the server's time. The server records with every write the caller value of [design 0001's event envelope](0001-evidence-ledger.md#events): the host, the Baley session the server minted, the call identity and its source, the client version and the host's own session when the host supplies them and, for a worker, its work order id. The `tools/call` decisions run one at a time on the server's own worker, behind one bounded queue that the session and its subagents share: one call running, four waiting and 16 MiB of raw frame bytes among them. A call past either bound is answered `failed` with code `server-overloaded`, which is retryable, before anything is prepared (SYS-R5). | Every call is tied to its project and session without asking the host for more, a read cannot change the record, and a write is checked the way the command line checks one. The record says who did what. | SYS-R5, CFG-R4 | Active | | HST-R8 | Long work (a suite, a check, a landing step) is claimed and started, and the call answers when the host allows it: on a host that keeps a main-session call open past its foreground limit, the call returns on completion; everywhere else the call returns a handle and `still running` after a bounded wait, and the caller repeats the call with the handle until it completes. The adapter chooses; the work runs the same either way, and a handle survives a session's reconnect. | Hosts limit how long a tool call may run; the work must not. | SYS-R7 | Active | | HST-R9 | Baley's questions to the owner are returned in the tool result as typed questions with their options; the host session puts them to the owner and returns the answer through the answering operation, deciding nothing itself. Where the adapter has proven elicitation reaches the person (a main session on Claude Code), it may use it for the same questions with the same records. Every answer is recorded with `owner.name` and Baley's clock. | The owner answers, the session relays, the record holds the answer. | SYS-P5, CFG-R8 | Active | -| HST-R10 | The owner's identity on every approval is `owner.name` from the global settings, defaulting to git `user.name`. The time is Baley's clock when the approval arrives. This identity setting is planned for the approval build; until that build adds it, T16 records git `user.name` as the owner identity on `providers.acknowledged`. T16 is not the setting's first reader, and the built thirteen-question settings interview does not ask it. A session never supplies either. | One owner, one clock. | SYS-P5 | Active | +| HST-R10 | The owner's identity on every approval is `owner.name` from the global settings, defaulting to git `user.name`. The time is Baley's clock when the approval arrives. This identity setting is planned for the approval build; until that build adds it, the interview records git `user.name` as the owner identity on `providers.acknowledged` (`crates/baley/src/providers/identity.rs:22-26`). The interview is not the setting's first reader, and it asks no `owner.name` question. A session never supplies either. | One owner, one clock. | SYS-P5 | Active | | HST-R11 | Every instruction a model sees is compiled into the binary and served by identity in parts; each carries a version and a hash, recorded as instruction evidence in the caller of every event a work-order or front-door call appends. There is no disk loader and no owner override. | One source of every instruction, provable after the fact. | SYS-P9, ADR 0009 | Active | | HST-R12 | Claude Code loads only stubs, written by `baley install` from the binary's tables into Claude Code's user-level locations, without a plugin, never committed and never hand-edited (the guard denies the write): a skill stub per front door (frontmatter plus one line naming the operation that returns the instructions), and an agent definition stub per role and rung (frontmatter with model and effort plus one line pointing at Baley), because the host reads effort from the definition. A rung's stub carries that rung's own effort level, since Claude Code's map is the identity map (CFG-R15). Support depends on the model: Claude Code runs a level a model does not support as the highest supported level at or below it (Opus and Sonnet 4.6 have no `xhigh`), and a model without effort (Haiku) gets none, recorded as not applicable and never a made-up level. The work order's route records the requested rung and the effective level apart (CFG-R17), from a dated per-model table of supported levels whose source is Claude Code's model configuration documentation (https://code.claude.com/docs/en/model-config#adjust-effort-level). An alias not yet resolved, or a host cap on effort, stays recorded as unknown until observed. `RungMap` is unchanged. Build 4's first dispatch task builds the table and the records, and the owner reviews the initial table in its pull request. `baley install` renders the stubs for each version. Updates activate them for new sessions only, with the stable-path wiring (HST-R17); `baley doctor` checks them byte for byte against the version they serve. | Roles and rungs are Baley's tables; what a host needs on disk follows from them and cannot drift. | ADR 0009, CFG-R12, CFG-R15, CFG-R17 | Active | | HST-R13 | The server's MCP instructions field holds one line naming the `help` operation and nothing else. | Claude Code cuts or hides it; the stubs carry the entry points. | | Active | @@ -154,7 +154,7 @@ The stable path is `$HOME/.local/bin/baley`, a symbolic link to `$HOME/.local/li The script allows an absent stable path or a link whose stored target is exactly the absolute `//baley`, with the version spelled by the same rule as the updater. It refuses other occupants and links to folders, and rechecks before activation. A reused staged file must be regular, not a link, have the manifest's digest and be executable. For a new staged file, the script sets mode `755` on the downloaded file before moving it into the versions folder, then moves it to its final name. It creates a temporary link in `~/.local/bin` and moves that link onto the stable path. It removes no version. Its receipt prints `baley: placed version at `, then `baley: versions present in :` and the version folders holding regular binaries. A failure of the script's own steps, before its last one, exits 1. The last step runs `"$stable" install`, after the receipt, and the installer ends with that command's exit status (0, 1, 2 or 3, as the receipt section below lists), so a refused or partial install fails the installer. The script itself writes no host wiring, records no install event and seeds no catalog: `baley install` does that (`install.sh:73-90, 173-212`). -`baley install` writes Claude Code's wiring at the stable path and takes setup defaults. T16 adds provider choice and acknowledgement, and T17 checks the installed result and runs the script live. A new Claude Code session loads the wiring, and each checkout still needs the owner's `baley init`. The command is `baley install [--host ]` (`crates/baley/src/install/command.rs:28-52`, `crates/baley/src/main.rs:37-38, 120`). +`baley install` writes Claude Code's wiring at the stable path and takes setup defaults. Provider choice and acknowledgement belong to `baley config interview` (section 5), and T17 checks the installed result and runs the script live. A new Claude Code session loads the wiring, and each checkout still needs the owner's `baley init`. The command is `baley install [--host ]` (`crates/baley/src/install/command.rs:28-52`, `crates/baley/src/main.rs:37-38, 120`). - **Inputs:** the host, default `claude-code`, and `HOME`, `CLAUDE_CONFIG_DIR` and the resolved Baley folders. Any other host is refused `unknown-host` before anything else runs, naming the supported hosts from `Host::ALL`. Every executable reference install writes is the absolute stable path, `~/.local/bin/baley`, from the installation layout and never from the running binary's own path (`crates/baley/src/install/command.rs:116-126`, `crates/baley/src/host_artifacts/installed.rs:55-107`). - **Outputs:** the artifacts in the table below, then the catalog seed and `install.recorded`. Existing owner settings and separately owned registrations are preserved. Replacement needs evidence of Baley's ownership, never a matching name alone (UPK-R2 in [0015](0015-repository-upkeep-and-build-gate.md)). @@ -217,7 +217,7 @@ Beside these, a `HOME`, `CLAUDE_CONFIG_DIR` or Baley folder value that cannot pl The outcome is complete only when every artifact is Baley's after the run, there is no gap, the catalog seed is recorded and no write or record failed. The exit status is 0 for complete and 1 for partial, 2 for a refusal, and a store failure uses the shared classes (2 for a refused or blocked store, 3 for a busy, unavailable or read-only one). -**The settings file.** Claude Code reads the user settings file below managed settings, command-line settings, a project's local settings and its shared settings. A project's own settings can therefore override single sandbox values such as `sandbox.enabled`, and a managed policy overrides them all. The sandbox in the user file applies to the Claude Code sessions that read that file, including projects Baley does not manage. Which file a session reads depends on `CLAUDE_CONFIG_DIR`: a session run with the value install saw, with none when none was set then, or with another value that names the same folder, reads the same file and is covered. It does not reach another user's sessions or a session that reads a different settings file, such as one run with `CLAUDE_CONFIG_DIR` set to another folder, and the receipt says so. Baley chose this file because it needs no administrator rights and holds the hook, the deny rules and the sandbox as one document that is composed once. Install writes nothing to `config.toml`: the interview's defaults are the compiled defaults, and a setting the owner changed later is not touched. Provider hosts and `review.reviewers` are T16's. +**The settings file.** Claude Code reads the user settings file below managed settings, command-line settings, a project's local settings and its shared settings. A project's own settings can therefore override single sandbox values such as `sandbox.enabled`, and a managed policy overrides them all. The sandbox in the user file applies to the Claude Code sessions that read that file, including projects Baley does not manage. Which file a session reads depends on `CLAUDE_CONFIG_DIR`: a session run with the value install saw, with none when none was set then, or with another value that names the same folder, reads the same file and is covered. It does not reach another user's sessions or a session that reads a different settings file, such as one run with `CLAUDE_CONFIG_DIR` set to another folder, and the receipt says so. Baley chose this file because it needs no administrator rights and holds the hook, the deny rules and the sandbox as one document that is composed once. Install writes nothing to `config.toml`: the interview's defaults are the compiled defaults, and a setting the owner changed later is not touched. Provider hosts and `review.reviewers` are written by `baley config interview`, never by install (section 5). **Limits.** Two cases are not closed. Between install's second read of `mcpServers.baley` and the `claude mcp remove` that follows it, another writer can change the entry, so the remove can delete an entry install did not judge ([#251](https://github.com/crenshawdev/baley/issues/251)). When nothing runnable is at the stable path, install withholds the new hook but still removes an older hook the record lists, so a machine can end with no hook ([#252](https://github.com/crenshawdev/baley/issues/252)); the gap says so only by naming the empty stable path. The live behaviour of the written wiring in a Claude Code session is T17's to observe. @@ -225,7 +225,7 @@ Install resolves the folders for its deny paths without pinning folder environme ### Sandbox and provider access -The library renders the filesystem and permission proposal below, and `baley install` merges it into the user settings file `/settings.json`, replacing the example paths with the resolved absolute folders (section 5). The example shows Linux defaults for a home folder of `/home/owner`, with the files a default installation places and, last, the provider hosts T16 writes when both providers are selected and acknowledged. Install itself writes no `network` block: without outside providers it adds neither API host. Existing unrelated settings and hosts are preserved. The fields follow Claude Code's [sandbox settings](https://code.claude.com/docs/en/sandboxing). +The library renders the filesystem and permission proposal below, and `baley install` merges it into the user settings file `/settings.json`, replacing the example paths with the resolved absolute folders (section 5). The example shows Linux defaults for a home folder of `/home/owner`, with the files a default installation places and, last, the provider hosts the interview adds when both providers are chosen and acknowledged. Install itself writes no `network` block and adds neither API host. It keeps every host the file already holds, as it keeps any unrelated key. The fields follow Claude Code's [sandbox settings](https://code.claude.com/docs/en/sandboxing). ```json { @@ -272,9 +272,9 @@ The library renders the filesystem and permission proposal below, and `baley ins Sandbox filesystem paths use ordinary absolute paths; `Read` and `Edit` rules use `//` for an absolute path. `Edit` covers Write and NotebookEdit. The guard also covers Read, Grep and Glob over the home and config folder, and protects the project file ([0010](0010-guard.md), GRD-R11 and GRD-R13); the placed stubs, the placed settings and registration files and the installed executable are on its list, and the versions folder is its write-only folder. Each write-only file gets a `denyWrite` entry and an `Edit` file rule, with no `Read` rule. In the default layout, the versions folder is `~/.local/lib/crenshawdev/baley/versions`, outside both the PATH folder and Baley's ledger home. Setting `BALEY_HOME` to the versions folder, a folder that contains it, or a version folder inside it is not supported: the home's read denials would cover the staged binaries, so sandboxed commands could not run them. The proposal protects it as a folder: its absolute path in `denyWrite` and `Edit(///**)`, with no `denyRead` entry or `Read` rule, so sandboxed commands can run the binaries inside it. `PlacementMap::write_only_folders` supplies that folder separately from protected files, and the guard's write decision denies writes to the folder and its contents (`crates/baley/src/host_artifacts/placement.rs:290-330`, `crates/baley/src/protected_paths/write.rs:13-31, 75-103`). The live guard derives the same placements from the environment: the stable path and versions folder from `HOME`, and the Claude folder from `CLAUDE_CONFIG_DIR`. It reads no store for them and passes them at `guard_hook::context::judge`, which protects the files once each and the versions folder as a write-only folder. When the placements cannot be derived, because `HOME` or `CLAUDE_CONFIG_DIR` is unusable, the guard cannot clear any path call and fails closed: it denies the six path tools and cannot record, so an ask becomes a deny (`crates/baley/src/guard_hook/context.rs:65-90, 103-174`, `crates/baley/src/host_artifacts/installed.rs:55-107`). The doctor builds its map from the install record (see `baley doctor`). There is no key folder, no provider-tool state exception and no `~/.codex` allowance. -The library renders this shape, without the `network` block, from the resolved folders, the executable, the write-only files and the write-only folders, and reports a supplied path that is not absolute UTF-8, that a rule would read as a pattern, or that ends in a space a deny rule would drop, instead of rendering it (`crates/baley/src/host_artifacts/security.rs:91-109, 143-222`). It composes the shape into an existing settings document, keeping every unrelated key, `sandbox.network` included. Where the shape is composed, Baley's secure values replace a disabled sandbox, `failIfUnavailable` false and `allowUnsandboxedCommands` true, with each replacement reported. Composition reports and retains `sandbox.filesystem.disabled` true, `disableAllHooks` true, and `sandbox.enabled` false, `failIfUnavailable` false or `allowUnsandboxedCommands` true in a document the shape is not composed into, such as one holding only the hook or the registration. It also reports and retains every `sandbox.excludedCommands` entry, whatever it names, since Claude Code runs a listed command outside the sandbox even with `allowUnsandboxedCommands` false; the home or config folder re-opened by an absolute `allowRead` or `allowWrite` entry; a `~/` or relative `allowRead` or `allowWrite` entry, which Claude Code resolves against the owner's home folder or the settings file's place and which composition therefore cannot judge; a differing hook running `guard`; and a `mcpServers.baley` entry with another command or arguments, with `env` entries, or of a transport other than `stdio`. A conflict marks the result incomplete. `baley install` sorts what composition reports into refusals, replacements and gaps (section 5) (`crates/baley/src/host_artifacts/compose.rs:98-175, 206-287`, `crates/baley/src/install/settings.rs:352-489`). The coverage judge reports what the resulting document covers per tool and per read or write (`crates/baley/src/host_artifacts/coverage.rs:281-356`). Any excluded command leaves Bash, Monitor and PowerShell and every write-only file a gap (`crates/baley/src/host_artifacts/coverage.rs:375-398`), and a `~/` or relative allow entry leaves each verdict its list can open a gap, reported as not judged (`crates/baley/src/host_artifacts/coverage.rs:429-442`). Both read only these settings, in the one document they are given: a setting Claude Code also honours, such as `sandbox.enabledPlatforms` leaving out this platform, or one in another settings file, is not seen. The placement projection carries the installed executable path and the separate versions folder (`crates/baley/src/host_artifacts/placement.rs:290-330`). The coverage judge currently takes only home, config and write-only files, so the installed versions folder needs T17's checks. Install applies the composed result to the settings file, and the guard protects the same placements. The `network` block is T16's, written for the chosen providers; neither the library nor install renders a network key. +The library renders this shape, without the `network` block, from the resolved folders, the executable, the write-only files and the write-only folders, and reports a supplied path that is not absolute UTF-8, that a rule would read as a pattern, or that ends in a space a deny rule would drop, instead of rendering it (`crates/baley/src/host_artifacts/security.rs:92-110, 144-223`). It composes the shape into an existing settings document, keeping every unrelated key, `sandbox.network` included. Where the shape is composed, Baley's secure values replace a disabled sandbox, `failIfUnavailable` false and `allowUnsandboxedCommands` true, with each replacement reported. Composition reports and retains `sandbox.filesystem.disabled` true, `disableAllHooks` true, and `sandbox.enabled` false, `failIfUnavailable` false or `allowUnsandboxedCommands` true in a document the shape is not composed into, such as one holding only the hook or the registration. It also reports and retains every `sandbox.excludedCommands` entry, whatever it names, since Claude Code runs a listed command outside the sandbox even with `allowUnsandboxedCommands` false; the home or config folder re-opened by an absolute `allowRead` or `allowWrite` entry; a `~/` or relative `allowRead` or `allowWrite` entry, which Claude Code resolves against the owner's home folder or the settings file's place and which composition therefore cannot judge; a differing hook running `guard`; and a `mcpServers.baley` entry with another command or arguments, with `env` entries, or of a transport other than `stdio`. A conflict marks the result incomplete. `baley install` sorts what composition reports into refusals, replacements and gaps (section 5) (`crates/baley/src/host_artifacts/compose.rs:98-175, 206-287`, `crates/baley/src/install/settings.rs:352-489`). The coverage judge reports what the resulting document covers per tool and per read or write (`crates/baley/src/host_artifacts/coverage.rs:281-356`). Any excluded command leaves Bash, Monitor and PowerShell and every write-only file a gap (`crates/baley/src/host_artifacts/coverage.rs:375-398`), and a `~/` or relative allow entry leaves each verdict its list can open a gap, reported as not judged (`crates/baley/src/host_artifacts/coverage.rs:429-442`). Both read only these settings, in the one document they are given: a setting Claude Code also honours, such as `sandbox.enabledPlatforms` leaving out this platform, or one in another settings file, is not seen. The placement projection carries the installed executable path and the separate versions folder (`crates/baley/src/host_artifacts/placement.rs:290-330`). The coverage judge currently takes only home, config and write-only files, so the installed versions folder needs T17's checks. Install applies the composed result to the settings file, and the guard protects the same placements. The `network` block is the interview's, written for the chosen providers through the settings part of the installation writer; neither the library nor install renders a network key. -Build 3 T16 extends the interview with provider choice and typed acknowledgement ([0003](0003-configuration-and-routing.md), CFG-R28). The summary before the final `yes` lists every file to be written. That `yes` records the acknowledgement first, writes API-host allowances through the installation writer second, writes the global `review.reviewers` list third, and runs the settings `config set` last. These are separate writes. A failure stops at that step and reports what was written; re-running the interview reads the completed state and safely applies the remaining changes. It states: Baley never reads your keys. Every program Claude Code starts can see keys in your environment, agents and subagents included. Nothing scrubs a key a command prints. Your code goes to the chosen provider under its terms. The acknowledgement is recorded in the ledger and asked again when the warning changes. A fresh install enables no outside provider; a reinstall keeps acknowledged choices. Install never acknowledges a warning. +`baley config interview` takes provider choice and typed acknowledgement after its thirteen settings questions ([0003](0003-configuration-and-routing.md), CFG-R28, section 5). The owner answers `openai`, `deepseek`, both separated by a comma, or `none`, and a blank answer keeps the stored choice (`crates/baley/src/config_command/interview/providers.rs:73-107, 116-180`). For each provider the owner names, the interview shows the warning and takes a typed `yes`. The warning states: Baley never reads your API keys. Keys live in your environment, where every program Claude Code starts can see them, agents and subagents included. Nothing scrubs a key a command prints. Your code goes to the chosen provider under its terms. It then reads git `user.name` as the owner and refuses `owner-identity-missing` before the summary when git has none. The summary before the final `yes` lists every file the writes can change or create (`crates/baley/src/config_command/interview/summary.rs:58-166`). That `yes` records the acknowledgement first, or reuses a current one, adds the chosen providers' API hosts second, writes the global `review.reviewers` list third, and runs the settings `config set` last (`crates/baley/src/config_command/interview.rs:140-185`). The host step is the settings-file part of the installation writer: it reads the user settings file as install reads it, adds each chosen provider's host to `sandbox.network.allowedDomains`, keeps every other key and host, and writes through the digest-checked replacement, so a file changed since it was read is refused as `install-settings-conflict` (`crates/baley/src/install/hosts.rs:91-190`). It takes no install lock, records no `install.recorded`, starts no `claude` command and writes no stub. Hosts are only added, so choosing `none` later leaves them in place. These are separate writes. A failure stops at that step and reports its code and what was written, and running the interview again reads the completed state and applies only the remaining changes. The acknowledgement is recorded in the ledger and asked again when the warning changes. A fresh install enables no outside provider, and a reinstall keeps acknowledged choices. Install never acknowledges a warning. ### baley update @@ -290,7 +290,7 @@ The check takes an `update.check` claim before any fetch. Only a new claim permi 4. Recheck the stable link, create a temporary link beside it and activate with one atomic rename exchange of the two links (`renameat2` with `RENAME_EXCHANGE` on Linux, `renamex_np` with `RENAME_SWAP` on macOS). Sync the stable folder and inspect the displaced link. An unexpected occupant is restored by another exchange. An activation error attempts restoration where an exchange occurred; a failed restoration reports the retained temporary entry for inspection. No running executable is overwritten (`crates/baley/src/update/deliver.rs:159-195, 290-408`). 5. Stop lease renewal and complete the claim with its `update.checked` or `update.failed` event in the same transaction. Only after that completion attempt returns does a successfully activated version run its own hidden `baley update seed`, directly from its staged path, to record its compiled catalog without a provider call. The seed has a 60-second deadline and a 4 KiB output bound. A newer view set raised during seeding therefore cannot fence the older updater's completion attempt. The seed still runs if completion failed; the receipt then reports an incomplete record and the new active version (`crates/baley/src/update/command.rs:215-239`, `crates/baley/src/update/record.rs:51-82`, `crates/baley/src/update/seed.rs:34-101`). -Every version is kept. An activation receipt lists the version folders holding regular binaries, including the active version, in numeric order. #14 owns pruning at activation. New sessions start the newly active server, while running servers keep their executable and loaded instructions. Each new hook call follows the stable path. The hook never rebuilds views. The view set is 8, raised once by T15's `install` view, and update events go only into `user`, so the hook adds no new event type to the running session's project. The rise has a cost. A binary at view set 7 is read-only on `user` once a newer binary has written there and brought its views to 8, so it cannot write the guard's records, the model catalog or update outcomes that live in `user`. A project is fenced for older binaries only after a view-set-8 binary has written to it, and install writes only `user`, so the session's own project stays writable for an older server. Binaries built before T14 cannot write `user` once it holds an update event, and binaries built before T15 cannot write it once it holds `install.recorded`, because they do not know the event types. No such binary was released, since only development binaries exist. A later binary that raises the ledger epoch can still make an older server read-only under [ADR 0034](../adr/0034-one-server-per-session.md). `baley install` writes the stubs new sessions load, and a running session keeps what it loaded; T17 qualifies the newer hook beside an older server (`crates/baley/src/ledger/open.rs:19-78`). +Every version is kept. An activation receipt lists the version folders holding regular binaries, including the active version, in numeric order. #14 owns pruning at activation. New sessions start the newly active server, while running servers keep their executable and loaded instructions. Each new hook call follows the stable path. The hook never rebuilds views. The view set is 8, raised once by T15's `install` view, and update events go only into `user`, so the hook adds no new event type to the running session's project. The rise has a cost. A binary at view set 7 is read-only on `user` once a newer binary has written there and brought its views to 8, so it cannot write the guard's records, the model catalog or update outcomes that live in `user`. A project is fenced for older binaries only after a view-set-8 binary has written to it, and install writes only `user`, so the session's own project stays writable for an older server. Binaries built before T14 cannot write `user` once it holds an update event, and binaries built before T15 cannot write it once it holds `install.recorded`, because they do not know the event types. No such binary was released, since only development binaries exist. A later binary that raises the ledger epoch can still make an older server read-only under [ADR 0034](../adr/0034-one-server-per-session.md). `baley install` writes the stubs new sessions load, and a running session keeps what it loaded; T17 qualifies the newer hook beside an older server (`crates/baley/src/ledger/open.rs:20-81`). The claim protocol in section 6 prevents manual and detached checks from overlapping. Only a detached check refuses a day already claimed, including one claimed manually. Manual checks can retry that day under a fresh request once the installation scope is free. @@ -323,7 +323,7 @@ Current and recorded activation outcomes exit 0, including an activation whose c | `not-writable` | Observing, writing, syncing, renaming or cleaning a delivery path failed. The cause names the path and operation's error | | `update-interrupted` | The claim was lost or could not be confirmed before staging or activation, or an interrupted check was resolved from local observations | -The claim renderer also has `update-check-needs-reconciliation` for an interrupted gate and `update-check-refused` as a fallback when a refused answer has no code. The command handles an interrupted gate by reconciling and retrying the claim once before rendering it. Invalid `HOME` and folder inputs retain their own diagnostic text. Unreadable or invalid global settings report `config-unavailable` with the path and cause (`crates/baley-core/src/policy/parse.rs:238-242, 259-264`). Store errors use the shared CLI renderer, not the MCP codes `ledger-busy` and `ledger-unavailable`: a busy store says `the ledger is busy; run the command again`, a read-only store names the needed epoch, and other errors retain their store text. There is no `unknown-host` or `update-signature-invalid` path in this command (`crates/baley/src/update/claim.rs:24-33, 218-245`, `crates/baley/src/update/receipt.rs:163-213`, `crates/baley/src/ledger/display.rs:101-146`). +The claim renderer also has `update-check-needs-reconciliation` for an interrupted gate and `update-check-refused` as a fallback when a refused answer has no code. The command handles an interrupted gate by reconciling and retrying the claim once before rendering it. Invalid `HOME` and folder inputs retain their own diagnostic text. Unreadable or invalid global settings report `config-unavailable` with the path and cause (`crates/baley-core/src/policy/parse.rs:246-250, 267-272`). Store errors use the shared CLI renderer, not the MCP codes `ledger-busy` and `ledger-unavailable`: a busy store says `the ledger is busy; run the command again`, a read-only store names the needed epoch, and other errors retain their store text. There is no `unknown-host` or `update-signature-invalid` path in this command (`crates/baley/src/update/claim.rs:24-33, 218-245`, `crates/baley/src/update/receipt.rs:163-213`, `crates/baley/src/ledger/display.rs:101-146`). ### baley update resolve @@ -344,7 +344,7 @@ With no owner hold it prints `no update check is held for this installation` and - the last recorded server call's `CLAUDE_PROJECT_DIR` and working directory on separate lines, read from the ledger and judged with the server's own rule (`crates/baley/src/host_doctor/server_context.rs:68-176`). An unusable variable is reported with `project-context-invalid`, the cause and what to set, and a missing one with `project-context-missing`. A working directory that differs from the project is valid and is not a finding. With no server call recorded, one line says so; - compatibility, where a ledger at a newer epoch is read-only for this binary, the write fence, where the server's startup `quick_check` is not observed from the command line and the `integrity_check` rows stand in for it, the home folder, and the config folder with a note when it is the home folder (`crates/baley/src/host_doctor/store_health.rs:49-63`, `crates/baley/src/host_doctor/report.rs:181-212`); - whether the guard's per-user records were behind this binary's when the doctor started, naming `baley rebuild user` (`crates/baley/src/host_doctor/guard_records.rs:38-78`). -- **Outputs, planned (T17):** the stable binary path and version, the installed MCP entry, hook and settings, the chosen provider hosts, the current warning acknowledgement and update setting, and a running session's version against the version staged for new sessions. T17 owns these checks under [ADR 0038](../adr/0038-installer-and-opt-in-updates.md). They run through the same observe, judge and report steps over the placement map built from the install record. The doctor reads no key and tests no credential. +- **Outputs, planned (T17):** the stable binary path and version, the installed MCP entry, hook and settings, the chosen provider hosts, the current warning acknowledgement and update setting, and a running session's version against the version staged for new sessions. T17 owns these checks under [ADR 0038](../adr/0038-installer-and-opt-in-updates.md). They run through the same observe, judge and report steps over the placement map built from the install record. The interview's host step adds provider hosts to the settings file without recording them, so the settings digest in `install.recorded` predates the file after it, and the doctor must not read that difference as drift (section 6). The doctor reads no key and tests no credential. - **Exit status:** placements or an install record that cannot be read, a gap in a given document, a placed file missing, unreadable or differing, a registration that differs, an executable that is missing, not a regular file or without an execute bit, a tool the hook leaves unguarded, `disableAllHooks` true in a hook or settings document, a missing sandbox program, an unsupported platform, a ledger at a newer epoch and guard records behind raise it to 1. An artifact that is not installed, the server's context and the folder lines do not. No host finding gives 2 or 3. - **Limits:** only the documents given are judged. Other Claude Code settings files and `sandbox.enabledPlatforms` can change what applies. WSL1, containers that block bubblewrap, Ubuntu's AppArmor rule on user namespaces, ripgrep and the seccomp filter are not checked. A missing project variable is never seen from the ledger, because a server without it records nothing, so the doctor sees only a recorded project folder that is no longer a directory. There is no plugin, so there is no check of plugin defaults ([ADR 0038](../adr/0038-installer-and-opt-in-updates.md)). - **Refusals:** none today. `unknown-host` comes with T17's host argument. A gap in a document names the setting to change, and a missing sandbox program names the package to install. An unwritable installed location is a failed check naming the location and its fix (T17). The host checks read the ledger and the placed files, append nothing and write no file. The store's own doctor runs before them and verifies each project's views, which first brings views behind this binary's current, so it can write the database. It appends no event. @@ -369,6 +369,8 @@ With no owner hold it prints `no update check is held for this installation` and The whole entries are kept so a later run can recognise an older Baley entry and replace it. A matching name alone never does (section 5). +The interview's host step adds provider hosts to the same settings file after install has recorded it. It takes no install lock and records no `install.recorded`, so after it the `sha256` that `sandbox` holds is the digest of the file as install left it and predates the file. That is not drift: T17's doctor must not read the older settings digest as drift, and the next `baley install` records the digest of the file as it finds it. + ### The caller on every recorded event Every event a call records carries one `caller` in its envelope, hashed with the event. [Design 0001's Events table](0001-evidence-ledger.md#events) holds the exact keys, values and limits. The caller has two forms. @@ -586,18 +588,21 @@ sequenceDiagram B->>L: seed the model catalog B->>L: record install.recorded when it differs from the latest record B-->>O: receipt, gaps with fixes, new session and per-checkout init steps - Note over O,C: Planned for T16, not built - O->>B: later config interview, choose providers and type acknowledgement - B-->>O: summary naming every file and change, ask final confirmation + Note over O,C: Later, the owner runs baley config interview + O->>B: answers the thirteen settings questions and the provider question + B-->>O: the warning for each chosen provider + O->>B: yes to each warning + B->>B: read git user.name as the owner + B-->>O: summary naming every file the writes can change, ask final confirmation O->>B: yes - B->>L: record selected providers and warning version - B->>HC: allow chosen API hosts through the installation writer + B->>L: record providers.acknowledged, or reuse a current one + B->>HC: add the chosen API hosts through the installation writer, no install lock and no install.recorded B->>C: write global review.reviewers and run its policy step B->>C: settings config set and its policy step - Note over B,C: Stop at the first failure and report completed writes, rerunning is safe + Note over B,C: Stop at the first failure and report the completed writes, rerunning is safe ``` -*Figure 7. The one-command installation as built, and T16's provider selection as planned. The script runs `baley install` through the stable path. Install takes its lock, reads the install record and the placed files, then writes the settings file and the stubs, asks `claude` to register the server, seeds the catalog and records the result. A refusal ends it before any write, and a write that fails stops the run and leaves a partial receipt. Default setup never supplies acknowledgement. The planned final confirmation authorizes separate writes in order, and a failure stops that sequence. The released installer verifies every download through #14's verifier; T14 and T17 use unsigned development artifacts only before #14.* +*Figure 7. The one-command installation, then the interview's provider setup, both as built. The script runs `baley install` through the stable path. Install takes its lock, reads the install record and the placed files, then writes the settings file and the stubs, asks `claude` to register the server, seeds the catalog and records the result. A refusal ends it before any write, and a write that fails stops the run and leaves a partial receipt. Default setup never supplies acknowledgement. The interview, run later, shows the warning for each provider the owner names and takes a typed `yes` to each. Its final confirmation authorizes four separate writes in order, a failure stops that sequence, and the host step takes no install lock and records no `install.recorded`. The released installer verifies every download through #14's verifier; T14 and T17 use unsigned development artifacts only before #14.* ```mermaid sequenceDiagram @@ -668,7 +673,7 @@ sequenceDiagram | `owner.name` | text | git `user.name` | global | 0012 | The owner recorded on every approval (HST-R10), added by the approval build | | `[host.]` sections | see [0003](0003-configuration-and-routing.md) | | both | 0003 | Per-host overrides the adapter applies | -Both update settings are in the standard schema used by `baley config set` and the settings interview (`crates/baley-core/src/policy/schema.rs:188-196, 332-346`). `owner.name` remains the approval build's setting. +Both update settings are in the standard schema used by `baley config set` and the settings interview (`crates/baley-core/src/policy/schema.rs:195-203, 342-356`). `owner.name` remains the approval build's setting. No setting selects, loads or overrides an instruction, because every instruction is compiled in (HST-R11). No setting chooses an artifact's content or placement either: the content is rendered from the compiled tables and supplied values (section 10), and the placement is derived from `HOME` and `CLAUDE_CONFIG_DIR` by `baley install`, the guard and the doctor alike, not set (section 5). @@ -693,7 +698,7 @@ Claude Code loads four artifacts Baley renders as content. Each is built from th | Skill stub, one per served front door (`bal-help`, `bal-capture`) | The compiled help table and instruction registry | Frontmatter `name` and `description`, then the one-line `baley_query` `instruction` call. There is no `allowed-tools` line, so each `baley_query` call is approved by the owner (`crates/baley/src/host_artifacts/stubs.rs:20-44`) | HST-R12 | `/skills//SKILL.md`, written by `baley install` | | MCP registration | The absolute executable, and whether to set `alwaysLoad` | `mcpServers.baley` with `command` the executable and `args` `["serve"]`, `alwaysLoad: true` only on request, and no `cwd`, `env`, URL, headers or token (`crates/baley/src/host_artifacts/registration.rs:7-26`) | HST-R17, HST-R20 | `mcpServers.baley` in `$CLAUDE_CONFIG_DIR/.claude.json`, else `~/.claude.json`, written by Claude Code through `claude mcp add-json --scope user`, which `baley install` runs. Install sets no `alwaysLoad` | | Guard hook | The absolute executable | One `PreToolUse` item matching `Bash\|Monitor\|PowerShell\|Read\|Grep\|Glob\|Write\|Edit\|NotebookEdit`, with one command hook that runs the single-quoted executable and `guard` within 10 seconds (`crates/baley/src/host_artifacts/hook.rs:13-50`) | GRD-R1 | The `hooks.PreToolUse` array of `/settings.json`, written by `baley install` | -| Sandbox and deny settings | The home and config folders, the executable, the write-only files and the write-only folders | The shape in section 5 without `sandbox.network`: home and config denied to reads and writes, the executable and each write-only file denied to writes, and the versions folder denied to writes with a folder rule and no read rule (`crates/baley/src/host_artifacts/security.rs:91-109, 143-222`) | GRD-R13, EVD-R24, CFG-R11, HST-R17 | The `sandbox` and `permissions.deny` keys of the same settings file, written by `baley install` | +| Sandbox and deny settings | The home and config folders, the executable, the write-only files and the write-only folders | The shape in section 5 without `sandbox.network`: home and config denied to reads and writes, the executable and each write-only file denied to writes, and the versions folder denied to writes with a folder rule and no read rule (`crates/baley/src/host_artifacts/security.rs:92-110, 144-223`) | GRD-R13, EVD-R24, CFG-R11, HST-R17 | The `sandbox` and `permissions.deny` keys of the same settings file, written by `baley install` | The manifest lists each stub's host, identity, bytes and lowercase hex SHA-256, and no placement enters it, so the same binary always gives the same manifest (`crates/baley/src/host_artifacts/stubs.rs:46-88`). The placement map links each artifact to a supplied absolute path or `Unknown` and yields the files the doctor expects and the protected list, which holds the placed stubs, the placed settings and registration files and the executable; an `Unknown` placement yields neither and is reported as not installed, never as protection. Install and the guard build the map from the environment (`HOME`, `CLAUDE_CONFIG_DIR` and the compiled manifest), and the doctor builds it from the same derivation and leaves an artifact `Unknown` until the install record shows it (`crates/baley/src/host_artifacts/installed.rs:55-107`, `crates/baley/src/host_doctor/mod.rs:61-120`). The versions folder has its own supplied placement or `Unknown` and yields a write-only folder, never an expected file. The map refuses a stub whose file, with `.` and `..` resolved from the text and no link followed, is another artifact's file or the executable (`crates/baley/src/host_artifacts/placement.rs:154-223, 250-339`). @@ -717,9 +722,9 @@ The library holds the per-session server (`crates/baley/src/mcp/`), and `baley s | HST-R14 | Not built | The parked engine builds a dispatch answer of an id and a route, never a prompt (`crates/baley/src/execution/boundary.rs:93-147`). The session server answers `execute-next` as unavailable (`crates/baley/src/mcp/operations.rs:114`) until Build 5 | | HST-R15 | Not built | `baley exec --key` still reads and injects a key and redacts output (`crates/baley/src/exec.rs`). The binary's HTTPS model lister still reads credentials (`crates/baley/src/detection/lister.rs`). Their removal belongs to Build 4 (#25). The parked review engine also holds its own provider calls (`crates/baley/src/review/provider/credentials.rs`). Build 4 owns the session review work order and raw-response parser. | | HST-R16 | Partly built | The CLI has `serve`, which Claude Code starts and the owner does not, `guard`, which runs the library hook for Claude Code's pre-tool call (`crates/baley/src/main.rs:135`, `crates/baley/src/guard_hook/mod.rs:36-150`), `skill-description`, the render commands, the ledger commands (`verify`, `doctor`, `export`, `purge`, `scrub`, `rebuild`, `anchor`, `acknowledge-restore`), `exec` (today only the `--key` credential wrapper ADR 0039 removes in Build 4), `artifact`, which prints Claude Code's artifacts and writes no file, `install`, which writes Claude Code's wiring at the stable path (`crates/baley/src/install/command.rs:28-52, 116-218`), `init`, `config` (`show`, `set` and `interview`), `models` (`list`, `add`, `remove` and `update`) and `update` (the foreground check and `resolve`, with hidden `detached` and `seed` subcommands) (`crates/baley/src/main.rs:23-100`, `crates/baley/src/update/command.rs:35-54`). The `exec` execution group is Build 5's. It has no `service` command, and the other commands are later builds. | -| HST-R17 | Partly built | Binary delivery is built for unsigned development artifacts: the installer verifies SHA-256, stages versions and places the stable link (`install.sh:37-210`), and `baley update` claims, downloads, verifies, stages, exchanges the active link, attempts completion with an update event and only then launches the new binary's catalog seed (`crates/baley/src/update/command.rs:108-326`, `crates/baley/src/update/deliver.rs:197-253, 325-408`). `baley serve` starts the opt-in detached check (`crates/baley/src/mcp/serve.rs:163-178`, `crates/baley/src/update/detached.rs:9-58`). Daily claims, update outcome events and owner recovery through `baley update resolve` are built (`crates/baley/src/update/claim.rs:62-169`, `crates/baley/src/update/events.rs:17-30, 127-180`, `crates/baley/src/update/reconcile.rs:200-340`). The view set is 8, raised once by the install projector, and the hook opens without rebuilding views (`crates/baley/src/ledger/open.rs:19-78`, `crates/baley/src/install/view.rs:11-90`). The versions folder is carried separately as a write-only folder and rendered with no read denial (`crates/baley/src/host_artifacts/placement.rs:290-330`, `crates/baley/src/host_artifacts/security.rs:143-222`). The registration, hook, security proposal, composition and coverage judge are built (`crates/baley/src/host_artifacts/registration.rs:12-26`, `crates/baley/src/host_artifacts/hook.rs:21-36`, `crates/baley/src/host_artifacts/compose.rs:98-126`, `crates/baley/src/host_artifacts/coverage.rs:281-356`). `baley install` is built: it writes the settings file with the hook, the deny rules and the sandbox block, writes the stubs, runs `claude mcp add-json --scope user baley`, seeds the catalog and records `install.recorded` (`crates/baley/src/install/command.rs:116-218`, `crates/baley/src/install/plan.rs:109-256`, `crates/baley/src/install/registration.rs:66-115`, `crates/baley/src/install/record.rs:47-118`). It proves ownership from the install record before replacing anything, refuses an artifact it cannot prove, reports gaps as a partial install, and holds one lock for its whole run (`crates/baley/src/install/stubs.rs:26-71`, `crates/baley/src/install/settings.rs:352-489`, `crates/baley/src/install/command.rs:54-114`). The script's last step runs it through the stable path (`install.sh:212`). The live guard derives the installed placements from the environment and protects them (`crates/baley/src/guard_hook/context.rs:103-174`). No hook file is tracked in the repository any more, and no test holds one to the renderer: the hook exists as the item install writes from the renderer (`crates/baley/src/host_artifacts/hook.rs:21-36`, `crates/baley/src/guard_budget.rs:20-22`). Binaries built before T15 cannot write `user` once it holds an `install.recorded`. T16 owns provider choice, typed acknowledgement, installation-writer API-host allowances and the separate global reviewer-list write before settings `config set`. The runtime doctor gathers, judges and reports the documents and ledger it is given over a placement map built from the install record, so an artifact the record does not show is reported as not installed (`crates/baley/src/host_doctor/mod.rs:61-120, 172-211, 353-484`, `crates/baley/src/host_doctor/report.rs:26-144`). T17 owns installed-result checks of the stable path and version, registration, hook, settings, provider hosts, warning acknowledgement and update setting, plus live qualification of the script and the newer hook beside an older server. #14 owns signature verification at both `verify_download` seams, the trust root, release format and publication. Both verifiers must be supplied and qualified before any release. #14 also owns pruning at activation. Removal of the built key paths belongs to Build 4 (#25). | -| HST-R18 | Built | The `failed` answer is built: a code, a place, `recorded: false` and `retryable`, returned as a successful tool result (`crates/baley/src/envelope.rs:82-153`, `crates/baley/src/mcp/gate.rs:66-69`). The server uses it for `unknown-host` (`crates/baley/src/mcp/gate.rs:162-178`), `server-overloaded` (`crates/baley/src/mcp/admission.rs:50-59`) and the project and caller faults (`crates/baley/src/mcp/gate.rs:112-151`). Only `server-overloaded` and `ledger-busy` are retryable (`crates/baley/src/envelope.rs:105-114`). Preparation answers each fault as `failed`: its codes and places (`crates/baley/src/mcp/prepare.rs:34-56`, `crates/baley-core/src/policy/parse.rs:16`, `crates/baley-core/src/checkout/judge.rs:12`), the answers a store failure, a git fault and a fork get (`crates/baley/src/mcp/prepare.rs:217-261`, `crates/baley/src/checkout/mod.rs:22-63`) and where the plan raises each (`crates/baley/src/mcp/prepare.rs:263-415`). A ledger that could not be opened at start answers `ledger-unavailable` (`crates/baley/src/mcp/serve.rs:73-100`). Checkout admission and the policy step are separate transactions, so a checkout admission recorded before a later step failed stays recorded (`crates/baley/src/mcp/prepare.rs:1-13`). `capture` answers a store fault `failed`, `ledger-busy` as retryable and any other as `ledger-unavailable`, answers bytes that were purged before `failed` `text-purged`, not retryable, and answers `request-id-reuse` as a refusal that records nothing (`crates/baley/src/mcp/capture.rs:324-378`). `document` maps its read faults the same way (`crates/baley/src/mcp/document.rs:215-323`). A capture's argument refusals are answered before preparation and record nothing (`crates/baley/src/mcp/capture.rs:87-167, 283-287`), and a named phase is refused `no-such-phase` inside the capture's transaction and recorded as a refused `command.completed` (`crates/baley/src/mcp/capture.rs:230-245`) | -| HST-R19 | Partly built | The decoder bounds a whole frame at 4 MiB and its depth at 128 (`crates/baley/src/mcp/frame.rs:14-18, 153, 246`). A frame over a bound or not JSON is discarded to its newline and answered with a JSON-RPC error while reading goes on (`crates/baley/src/mcp/frame.rs:470-493`, `crates/baley/src/mcp/transport.rs:52-73, 218-247`). Hook input is bounded (`crates/baley/src/hook_input/mod.rs:19-21`, `crates/baley/src/guard_hook/mod.rs:50-61`). Every git child runs with a deadline that `validate_launch` enforces (`crates/baley/src/process.rs:243-284`, `crates/baley/src/git_process.rs:49-99`): a caller outside the guard only at its exact registered deadline, and the guard's two callers, for the branch and for HEAD's copy of `baley.toml`, only at the timeout a budget grant gave the launch, above zero and at most 5 seconds, in their own process group. Only a grant sets that timeout, so one set by hand is refused (`crates/baley/src/process.rs:132-142`, `crates/baley/src/guard_budget.rs:34-116`). The detached updater has no process deadline and is never waited on (`crates/baley/src/update/detached.rs:21-25, 47-50`); its downloads are bounded, and the catalog-seed child has a 60-second deadline (`crates/baley/src/update/fetch.rs:12-16`, `crates/baley/src/update/seed.rs:34-44`). The suite runner's `sh -c` (`crates/baley/src/execution/runner.rs`) runs with none, owned by Build 5, and the still-built `baley exec --key` has no deadline. Its removal belongs to Build 4 (#25) under ADR 0039. | +| HST-R17 | Partly built | Binary delivery is built for unsigned development artifacts: the installer verifies SHA-256, stages versions and places the stable link (`install.sh:37-210`), and `baley update` claims, downloads, verifies, stages, exchanges the active link, attempts completion with an update event and only then launches the new binary's catalog seed (`crates/baley/src/update/command.rs:108-326`, `crates/baley/src/update/deliver.rs:197-253, 325-408`). `baley serve` starts the opt-in detached check (`crates/baley/src/mcp/serve.rs:163-178`, `crates/baley/src/update/detached.rs:9-58`). Daily claims, update outcome events and owner recovery through `baley update resolve` are built (`crates/baley/src/update/claim.rs:62-169`, `crates/baley/src/update/events.rs:17-30, 127-180`, `crates/baley/src/update/reconcile.rs:200-340`). The view set is 8, raised once by the install projector, and the hook opens without rebuilding views (`crates/baley/src/ledger/open.rs:20-81`, `crates/baley/src/install/view.rs:11-90`). The versions folder is carried separately as a write-only folder and rendered with no read denial (`crates/baley/src/host_artifacts/placement.rs:290-330`, `crates/baley/src/host_artifacts/security.rs:144-223`). The registration, hook, security proposal, composition and coverage judge are built (`crates/baley/src/host_artifacts/registration.rs:12-26`, `crates/baley/src/host_artifacts/hook.rs:21-36`, `crates/baley/src/host_artifacts/compose.rs:98-126`, `crates/baley/src/host_artifacts/coverage.rs:281-356`). `baley install` is built: it writes the settings file with the hook, the deny rules and the sandbox block, writes the stubs, runs `claude mcp add-json --scope user baley`, seeds the catalog and records `install.recorded` (`crates/baley/src/install/command.rs:116-218`, `crates/baley/src/install/plan.rs:109-256`, `crates/baley/src/install/registration.rs:66-115`, `crates/baley/src/install/record.rs:47-118`). It proves ownership from the install record before replacing anything, refuses an artifact it cannot prove, reports gaps as a partial install, and holds one lock for its whole run (`crates/baley/src/install/stubs.rs:26-71`, `crates/baley/src/install/settings.rs:352-489`, `crates/baley/src/install/command.rs:54-114`). The script's last step runs it through the stable path (`install.sh:212`). The live guard derives the installed placements from the environment and protects them (`crates/baley/src/guard_hook/context.rs:103-174`). No hook file is tracked in the repository any more, and no test holds one to the renderer: the hook exists as the item install writes from the renderer (`crates/baley/src/host_artifacts/hook.rs:21-36`, `crates/baley/src/guard_budget.rs:20-22`). Binaries built before T15 cannot write `user` once it holds an `install.recorded`. Provider choice, the typed acknowledgement, the API-host allowances through the installation writer's settings part and the separate global reviewer-list write before settings `config set` are built in `baley config interview` (`crates/baley/src/config_command/interview/providers.rs:116-180`, `crates/baley/src/providers/record.rs:102-166`, `crates/baley/src/install/hosts.rs:91-190`, `crates/baley/src/config_command/reviewers.rs:84-104`, `crates/baley/src/config_command/interview.rs:140-185`). The host step takes no install lock and records no `install.recorded`. The runtime doctor gathers, judges and reports the documents and ledger it is given over a placement map built from the install record, so an artifact the record does not show is reported as not installed (`crates/baley/src/host_doctor/mod.rs:61-120, 172-211, 353-484`, `crates/baley/src/host_doctor/report.rs:26-144`). T17 owns installed-result checks of the stable path and version, registration, hook, settings, provider hosts, warning acknowledgement and update setting, plus live qualification of the script and the newer hook beside an older server. #14 owns signature verification at both `verify_download` seams, the trust root, release format and publication. Both verifiers must be supplied and qualified before any release. #14 also owns pruning at activation. Removal of the built key paths belongs to Build 4 (#25). | +| HST-R18 | Built | The `failed` answer is built: a code, a place, `recorded: false` and `retryable`, returned as a successful tool result (`crates/baley/src/envelope.rs:82-153`, `crates/baley/src/mcp/gate.rs:66-69`). The server uses it for `unknown-host` (`crates/baley/src/mcp/gate.rs:162-178`), `server-overloaded` (`crates/baley/src/mcp/admission.rs:50-59`) and the project and caller faults (`crates/baley/src/mcp/gate.rs:112-151`). Only `server-overloaded` and `ledger-busy` are retryable (`crates/baley/src/envelope.rs:105-114`). Preparation answers each fault as `failed`: its codes and places (`crates/baley/src/mcp/prepare.rs:34-56`, `crates/baley-core/src/policy/parse.rs:18`, `crates/baley-core/src/checkout/judge.rs:12`), the answers a store failure, a git fault and a fork get (`crates/baley/src/mcp/prepare.rs:217-261`, `crates/baley/src/checkout/mod.rs:22-63`) and where the plan raises each (`crates/baley/src/mcp/prepare.rs:263-415`). A ledger that could not be opened at start answers `ledger-unavailable` (`crates/baley/src/mcp/serve.rs:73-100`). Checkout admission and the policy step are separate transactions, so a checkout admission recorded before a later step failed stays recorded (`crates/baley/src/mcp/prepare.rs:1-13`). `capture` answers a store fault `failed`, `ledger-busy` as retryable and any other as `ledger-unavailable`, answers bytes that were purged before `failed` `text-purged`, not retryable, and answers `request-id-reuse` as a refusal that records nothing (`crates/baley/src/mcp/capture.rs:324-378`). `document` maps its read faults the same way (`crates/baley/src/mcp/document.rs:215-323`). A capture's argument refusals are answered before preparation and record nothing (`crates/baley/src/mcp/capture.rs:87-167, 283-287`), and a named phase is refused `no-such-phase` inside the capture's transaction and recorded as a refused `command.completed` (`crates/baley/src/mcp/capture.rs:230-245`) | +| HST-R19 | Partly built | The decoder bounds a whole frame at 4 MiB and its depth at 128 (`crates/baley/src/mcp/frame.rs:14-18, 153, 246`). A frame over a bound or not JSON is discarded to its newline and answered with a JSON-RPC error while reading goes on (`crates/baley/src/mcp/frame.rs:470-493`, `crates/baley/src/mcp/transport.rs:52-73, 218-247`). Hook input is bounded (`crates/baley/src/hook_input/mod.rs:19-21`, `crates/baley/src/guard_hook/mod.rs:50-61`). Every git child runs with a deadline that `validate_launch` enforces (`crates/baley/src/process.rs:243-284`, `crates/baley/src/git_process.rs:52-103`): a caller outside the guard only at its exact registered deadline, and the guard's two callers, for the branch and for HEAD's copy of `baley.toml`, only at the timeout a budget grant gave the launch, above zero and at most 5 seconds, in their own process group. Only a grant sets that timeout, so one set by hand is refused (`crates/baley/src/process.rs:132-142`, `crates/baley/src/guard_budget.rs:34-116`). The detached updater has no process deadline and is never waited on (`crates/baley/src/update/detached.rs:21-25, 47-50`); its downloads are bounded, and the catalog-seed child has a 60-second deadline (`crates/baley/src/update/fetch.rs:12-16`, `crates/baley/src/update/seed.rs:34-44`). The suite runner's `sh -c` (`crates/baley/src/execution/runner.rs`) runs with none, owned by Build 5, and the still-built `baley exec --key` has no deadline. Its removal belongs to Build 4 (#25) under ADR 0039. | | HST-R20 | Built | Each descriptor carries the marker (`crates/baley/src/mcp/tools.rs:83-120`, asserted at `crates/baley/src/mcp/tools.rs:272-282`). The registration renders the server-level `alwaysLoad: true` only when asked (`crates/baley/src/host_artifacts/registration.rs:20-26`), and the 2026-10-08 runs tried the plain form: with a registration that has no `alwaysLoad`, all three tools were callable without a tool search in the main session and in a subagent, in a session that does defer other tools, so the tool-level marker works alone (`claude-live-2026-10-08-run1.md#hand.tools.explicit-main`, `#hand.tools.explicit-subagent`, and again `claude-live-2026-10-08-run2.md#hand.tools.explicit-main`, `#hand.tools.explicit-subagent`). The first two runs had no login into the isolated configuration, so they could not start a session with the `alwaysLoad` form. The third run did, after the owner's login: `claude mcp add-json --scope user` stored a registration with `alwaysLoad: true`, a running session listed `baley` with 3 tools from the user scope, and in that session, which defers other servers' tools, all three tools were callable without a tool search in the main session and in a subagent, whose capture carries the session's `baley_session`. Claude Code accepted the `alwaysLoad` form (`claude-live-2026-10-08-run3.md#ses.b.registered`, `#hand.tools.user-main`, `#hand.tools.user-subagent`) | ## 12. Open questions From fb03030a27599d155084d22f3c53cdbdbf5c31cd Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 20:48:24 -0400 Subject: [PATCH 22/27] docs(design): say a binary built before T16 cannot write user once it holds the acknowledgement The view set stays at 8, but the store refuses a transaction on a project holding an event type the binary cannot read, so the old claim that an older binary keeps writing user was wrong. --- docs/design/0003-configuration-and-routing.md | 2 +- docs/design/0012-host-interface.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/design/0003-configuration-and-routing.md b/docs/design/0003-configuration-and-routing.md index 2589f950..5a59f0a3 100644 --- a/docs/design/0003-configuration-and-routing.md +++ b/docs/design/0003-configuration-and-routing.md @@ -419,7 +419,7 @@ The per-user project `user` records the providers the owner confirmed, the compi | `owner` | text | git `user.name`, one line of text | | `acknowledged_at` | text | The time the interview recorded the event at | -The event is registered at version 1 with no upcasters (`crates/baley/src/ledger/open.rs:41`, `crates/baley/src/providers/event.rs:24-26`). It is recorded by a command of kind `providers.acknowledge`, actor `owner`, policy version 0, on the stream `providers` of `user`, bound to the stream version that was read, so a second acknowledgement recorded in between is refused as stale and not duplicated; the command creates `user` when it is absent (`crates/baley/src/providers/record.rs:126-166`). No view holds it: a reader reads the `providers` stream directly, as the update events are read, so the view set is unchanged and an older binary keeps writing `user`. A stored event covers a provider only when it carries the current warning version and digest and lists the provider as text. Several events may cover the named providers together, and the owner and the time are history that decide nothing (`crates/baley/src/providers/record.rs:78-121`). +The event is registered at version 1 with no upcasters (`crates/baley/src/ledger/open.rs:41`, `crates/baley/src/providers/event.rs:24-26`). It is recorded by a command of kind `providers.acknowledge`, actor `owner`, policy version 0, on the stream `providers` of `user`, bound to the stream version that was read, so a second acknowledgement recorded in between is refused as stale and not duplicated; the command creates `user` when it is absent (`crates/baley/src/providers/record.rs:126-166`). No view holds it: a reader reads the `providers` stream directly, as the update events are read, so the view set stays at 8. A binary built before T16 does not know the event type, so a ledger transaction on `user` is refused as read-only once `user` holds `providers.acknowledged` (`crates/baley-store-sqlite/src/transact.rs:390-406`). No such binary was released, since only development binaries exist. A stored event covers a provider only when it carries the current warning version and digest and lists the provider as text. Several events may cover the named providers together, and the owner and the time are history that decide nothing (`crates/baley/src/providers/record.rs:78-121`). Each provider has its own acknowledgement state. A current acknowledgement covers only its confirmed providers and warning version, and choosing another provider does not revoke an existing acknowledgement. A changed warning, in version or in wording, covers nothing stored and requires a new typed acknowledgement. Work-order issuance and fetch-command output are to check this record, including when settings were edited outside the interview (CFG-R28). That check is Build 4's. diff --git a/docs/design/0012-host-interface.md b/docs/design/0012-host-interface.md index 0cb885df..8ee027db 100644 --- a/docs/design/0012-host-interface.md +++ b/docs/design/0012-host-interface.md @@ -290,7 +290,7 @@ The check takes an `update.check` claim before any fetch. Only a new claim permi 4. Recheck the stable link, create a temporary link beside it and activate with one atomic rename exchange of the two links (`renameat2` with `RENAME_EXCHANGE` on Linux, `renamex_np` with `RENAME_SWAP` on macOS). Sync the stable folder and inspect the displaced link. An unexpected occupant is restored by another exchange. An activation error attempts restoration where an exchange occurred; a failed restoration reports the retained temporary entry for inspection. No running executable is overwritten (`crates/baley/src/update/deliver.rs:159-195, 290-408`). 5. Stop lease renewal and complete the claim with its `update.checked` or `update.failed` event in the same transaction. Only after that completion attempt returns does a successfully activated version run its own hidden `baley update seed`, directly from its staged path, to record its compiled catalog without a provider call. The seed has a 60-second deadline and a 4 KiB output bound. A newer view set raised during seeding therefore cannot fence the older updater's completion attempt. The seed still runs if completion failed; the receipt then reports an incomplete record and the new active version (`crates/baley/src/update/command.rs:215-239`, `crates/baley/src/update/record.rs:51-82`, `crates/baley/src/update/seed.rs:34-101`). -Every version is kept. An activation receipt lists the version folders holding regular binaries, including the active version, in numeric order. #14 owns pruning at activation. New sessions start the newly active server, while running servers keep their executable and loaded instructions. Each new hook call follows the stable path. The hook never rebuilds views. The view set is 8, raised once by T15's `install` view, and update events go only into `user`, so the hook adds no new event type to the running session's project. The rise has a cost. A binary at view set 7 is read-only on `user` once a newer binary has written there and brought its views to 8, so it cannot write the guard's records, the model catalog or update outcomes that live in `user`. A project is fenced for older binaries only after a view-set-8 binary has written to it, and install writes only `user`, so the session's own project stays writable for an older server. Binaries built before T14 cannot write `user` once it holds an update event, and binaries built before T15 cannot write it once it holds `install.recorded`, because they do not know the event types. No such binary was released, since only development binaries exist. A later binary that raises the ledger epoch can still make an older server read-only under [ADR 0034](../adr/0034-one-server-per-session.md). `baley install` writes the stubs new sessions load, and a running session keeps what it loaded; T17 qualifies the newer hook beside an older server (`crates/baley/src/ledger/open.rs:20-81`). +Every version is kept. An activation receipt lists the version folders holding regular binaries, including the active version, in numeric order. #14 owns pruning at activation. New sessions start the newly active server, while running servers keep their executable and loaded instructions. Each new hook call follows the stable path. The hook never rebuilds views. The view set is 8, raised once by T15's `install` view, and update events go only into `user`, so the hook adds no new event type to the running session's project. The rise has a cost. A binary at view set 7 is read-only on `user` once a newer binary has written there and brought its views to 8, so it cannot write the guard's records, the model catalog or update outcomes that live in `user`. A project is fenced for older binaries only after a view-set-8 binary has written to it, and install writes only `user`, so the session's own project stays writable for an older server. Binaries built before T14 cannot write `user` once it holds an update event, binaries built before T15 cannot write it once it holds `install.recorded`, and binaries built before T16 cannot write it once it holds `providers.acknowledged`, because they do not know the event types (`crates/baley-store-sqlite/src/transact.rs:390-406`). No such binary was released, since only development binaries exist. A later binary that raises the ledger epoch can still make an older server read-only under [ADR 0034](../adr/0034-one-server-per-session.md). `baley install` writes the stubs new sessions load, and a running session keeps what it loaded; T17 qualifies the newer hook beside an older server (`crates/baley/src/ledger/open.rs:20-81`). The claim protocol in section 6 prevents manual and detached checks from overlapping. Only a detached check refuses a day already claimed, including one claimed manually. Manual checks can retry that day under a fresh request once the installation scope is free. From 447308a49973b891d98e053fd19db97120c6af71 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 20:50:33 -0400 Subject: [PATCH 23/27] docs(design): say in design 0001 and the README that the provider choice and acknowledgement are built The README's warning is now design 0003's paragraph word for word, and its interview paragraph lists the four writes in order. Design 0001's providers row, interview row and policy-step paragraph match design 0003 section 5. --- README.md | 8 ++++---- docs/design/0001-evidence-ledger.md | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index e3320e6e..9db7b727 100644 --- a/README.md +++ b/README.md @@ -27,19 +27,19 @@ Baley keeps AI coding agents accountable to the person who answers for their wor ## Installing and updating -The development install script [`install.sh`](install.sh) and manual `baley update` are built for unsigned development artifacts only. Both check the downloaded binary's SHA-256 against an unsigned manifest before staging it behind `~/.local/bin/baley`. [#14](https://github.com/crenshawdev/baley/issues/14) adds and qualifies signature verification before any release. The script ends by running `baley install` through `~/.local/bin/baley`. Install writes Claude Code's wiring: the `baley` MCP server registration in `~/.claude.json`, made through `claude mcp add-json --scope user` so that Claude Code stays the only writer of that file, the pre-tool hook, the sandbox settings and the `Read` and `Edit` deny rules in `~/.claude/settings.json`, and one skill stub per front door under `~/.claude/skills/`. When `CLAUDE_CONFIG_DIR` is set, all three live under that folder instead. Install keeps your own settings and replaces an artifact only when it can show Baley wrote it. It refuses, and changes none of your files, when an artifact under Baley's name is not provably Baley's (`install-ownership-conflict`) or the settings file cannot be merged (`install-settings-conflict`). One case is still open: if another program changes the `baley` entry in `~/.claude.json` after install has checked it and before `claude mcp remove` runs, the remove can delete an entry install did not judge ([#251](https://github.com/crenshawdev/baley/issues/251)). Every run prints a receipt that says whether the install is complete or partial. It is partial, and the script exits non-zero, when one of your settings weakens the protection, when `bwrap` or `socat` is missing on Linux, or when nothing runnable sits at `~/.local/bin/baley`. The receipt names each gap and the fix, and you run install again afterward. The sandbox settings apply to the Claude Code sessions that read that settings file, including projects Baley does not manage. No npm or plugin is needed. Install takes settings defaults and writes nothing to `config.toml`; `baley config interview` remains available later. A new Claude Code session loads the wiring, and each checkout needs `baley init`. +The development install script [`install.sh`](install.sh) and manual `baley update` are built for unsigned development artifacts only. Both check the downloaded binary's SHA-256 against an unsigned manifest before staging it behind `~/.local/bin/baley`. [#14](https://github.com/crenshawdev/baley/issues/14) adds and qualifies signature verification before any release. The script ends by running `baley install` through `~/.local/bin/baley`. Install writes Claude Code's wiring: the `baley` MCP server registration in `~/.claude.json`, made through `claude mcp add-json --scope user` so that Claude Code stays the only writer of that file, the pre-tool hook, the sandbox settings and the `Read` and `Edit` deny rules in `~/.claude/settings.json`, and one skill stub per front door under `~/.claude/skills/`. When `CLAUDE_CONFIG_DIR` is set, all three live under that folder instead. Install keeps your own settings and replaces an artifact only when it can show Baley wrote it. It refuses, and changes none of your files, when an artifact under Baley's name is not provably Baley's (`install-ownership-conflict`) or the settings file cannot be merged (`install-settings-conflict`). One case is still open: if another program changes the `baley` entry in `~/.claude.json` after install has checked it and before `claude mcp remove` runs, the remove can delete an entry install did not judge ([#251](https://github.com/crenshawdev/baley/issues/251)). Every run prints a receipt that says whether the install is complete or partial. It is partial, and the script exits non-zero, when one of your settings weakens the protection, when `bwrap` or `socat` is missing on Linux, or when nothing runnable sits at `~/.local/bin/baley`. The receipt names each gap and the fix, and you run install again afterward. The sandbox settings apply to the Claude Code sessions that read that settings file, including projects Baley does not manage. No npm or plugin is needed. Install takes settings defaults and writes nothing to `config.toml`, and a reinstall keeps the API hosts the interview added to the settings file; `baley config interview` remains available later. A new Claude Code session loads the wiring, and each checkout needs `baley init`. Automatic updates are opt-in and off by default. Enable them with `baley config set --global updates.auto=true`. At startup, `baley serve` reads that setting once and starts a detached check without waiting for it. The check runs at most once per UTC day per installation, checks the download's SHA-256 against the unsigned manifest, and stages a newer version beside the old ones. Set the development source with `baley config set --global updates.source=https://...`; there is no default source. New sessions start the new server; running servers keep their version. The hook uses the new version at its next call and must work with an older running server and its ledger state. The updater contacts the configured source without sending project content or provider credentials. The guard never checks for updates or waits for them. Manual `baley update` can run even when that day's check has already run. See [delivery and updates](docs/adr/0038-installer-and-opt-in-updates.md). ## API keys and outside-review risks -The design requires that Baley never read, store or send your API keys. You keep them in your environment, where every program Claude Code starts can see them, agents and subagents included. Nothing scrubs a key a command prints. Your code goes to the review provider you choose under its terms. +Baley never reads your API keys. Keys live in your environment, where every program Claude Code starts can see them, agents and subagents included. Nothing scrubs a key a command prints. Your code goes to the chosen provider under its terms. Release 1 outside reviews use provider APIs only. Baley builds the request and names the address, header and environment variable; the session sends it with your key and returns the raw response for Baley to parse and check. The same boundary applies to model-list refreshes. Provider command-line agents are outside this release. -Before an outside provider is enabled, the interview asks which providers to use, states these risks and requires a typed confirmation. It records the acknowledgement in the ledger and asks again when the warning changes. A fresh install enables no outside provider; a reinstall keeps acknowledged choices. Install never acknowledges a warning for you. +No outside provider is enabled until you confirm it. `baley config interview` asks which providers to use (`openai`, `deepseek` or `none`) after the settings questions, shows the warning above for each one you pick and needs a typed `yes` to it. Any other answer declines the whole interview and writes nothing. It then shows a summary that names every file it will change. After the final `yes` it records the acknowledgement in the ledger with your git `user.name` (it refuses, before the summary, when git has none), adds the providers' API hosts to Claude Code's settings file, writes `review.reviewers` in `config.toml`, and then writes the settings. If a step fails, it reports the failure and the writes already completed, and running the interview again finishes what is missing. It asks again when the warning changes. A fresh install enables no outside provider; a reinstall keeps acknowledged choices and the hosts the interview added. Install never acknowledges a warning for you. -This boundary is not built yet. The current binary still contains the `keys.env` reader, `baley exec --key` and the HTTPS model lister. Build 4 (#25) removes those paths. The parked review engine also reads environment keys and `providers.env` and makes provider calls; that code goes with the parked engine in Build 9. See [the credential decision](docs/adr/0039-session-owned-provider-credentials.md) and [configuration build status](docs/design/0003-configuration-and-routing.md#11-build-status). +The key boundary the warning describes is not built yet. The current binary still contains the `keys.env` reader, `baley exec --key` and the HTTPS model lister. Build 4 (#25) removes those paths. The parked review engine also reads environment keys and `providers.env` and makes provider calls; that code goes with the parked engine in Build 9. See [the credential decision](docs/adr/0039-session-owned-provider-credentials.md) and [configuration build status](docs/design/0003-configuration-and-routing.md#11-build-status). ## Following the work diff --git a/docs/design/0001-evidence-ledger.md b/docs/design/0001-evidence-ledger.md index c7fb986c..ae1be793 100644 --- a/docs/design/0001-evidence-ledger.md +++ b/docs/design/0001-evidence-ledger.md @@ -427,7 +427,7 @@ Streams used by the record families: | `guard` | `guard.answered` and `guard.policy_recorded` ([0010](0010-guard.md) section 6). The stream lives only in the reserved per-user project `user`, at policy version 0, and its events carry the hook form of the caller, so the session's project is a fact in them and never the ledger project | | `command/` | `command.claimed`, `command.completed`, `command.reconciled` | | `retention` | `payload.reduced`, `payload.purged` | -| `providers` | `providers.acknowledged`, in the per-user project `user` at policy version 0 ([0003](0003-configuration-and-routing.md#6-records)); Build 3 T16 owns it | +| `providers` | `providers.acknowledged`, recorded by `baley config interview` in the per-user project `user` by a command of kind `providers.acknowledge`, actor `owner`, policy version 0 ([0003](0003-configuration-and-routing.md#6-records)). No view holds it: a reader reads this stream directly, as the update events are read, so the view set stays at 8 | | `install` | `update.checked` and `update.failed` are recorded in the per-user project `user`, at policy version 0. `baley install` records `install.recorded` there beside them, as the owner at policy version 0 with no caller, only when its payload differs from the latest record for the host. The `install` view holds the latest install record per host and nothing else, so update outcomes are read from the stream. Adding the view raised the view set to 8, so a binary built before it is read-only on `user` once a newer binary has brought its views to 8 ([0012](0012-host-interface.md#6-records)) | | `models` | `models.seeded`, `models.owner_changed`, `models.detected`, `models.detection_failed`. The stream lives only in the reserved per-user project `user`, whose records carry policy version 0 ([0003](0003-configuration-and-routing.md)) | @@ -1072,7 +1072,7 @@ The judgement reads the project's `checkout` view, which holds one row per path |---|---|---|---| | `baley init` | discovery at the repository root | yes, after `project.initialized` and checkout admission | 0 on `project.initialized` | | `baley config set` in a project | discovery | yes, after the write, when the project is in this machine's ledger | the version in force after it, or 0 when the project is not in this machine's ledger, where `baley init` records the policy | -| `baley config interview` | discovery for each TOML write, `user` for provider acknowledgement | T16: acknowledgement, host allowances, global reviewer list, then settings through the apply function shared with `config set`, bound to the shown file. No policy step for acknowledgement or host allowances; each TOML write uses the `config set` policy-step rules after its own write | 0 on the per-user acknowledgement; each TOML step records its resulting policy version when applicable | +| `baley config interview` | discovery for each TOML write, `user` for provider acknowledgement | After the final `yes`, in order: the acknowledgement, the API hosts, the global reviewer list, then the settings through the apply function shared with `config set`, bound to the shown file. The acknowledgement and the host step run no policy step. The reviewer-list write and the settings write each run the `config set` policy step after their own write, when the project is in this machine's ledger | 0 on the per-user acknowledgement; each of the other two steps records its resulting policy version when it applies | | `anchor`, `acknowledge-restore` | discovery | yes | the version in force | | `purge` | its argument | only when run from a checkout of that project | the version in force there, otherwise 0 | | `verify --local-only`, `verify --views`, `export`, `rebuild` | their argument | no: no event is appended | none | @@ -1086,7 +1086,7 @@ The judgement reads the project's `checkout` view, which holds one row per path | `policy.effective` itself | the command it belongs to | it is the step's own record | the version it replaces, 0 for the first of its key | | `checkout.seen` itself | the command it belongs to | no: it runs before the step, as a command of its own | the policy version stored for the checkout and no host, 0 when none is stored | -The policy step runs before every command that appends to a project's chain from a checkout, and a command that appends to no chain runs no step. It re-reads both files and is a command of its own, `policy.record` by actor `baley`, and it opens no command when nothing changed. The command line's step is keyed by the checkout and no host. The server's step is keyed by the host, records the policy with that host's sections applied, carries the call's caller, and returns the version its write records on its own command. A server read runs no step, and neither does a write whose request the ledger already holds. Checkout admission, described above, runs before the step for every command that runs one. Records in the per-user project `user` carry 0. Seeding and detection build no policy, and the guard builds one only to judge a commit, keeping from it only the denials it remembers (`guard.policy_recorded`), never a `policy.effective`. `purge` is the one chain-writing command that may run outside a checkout: it runs the step only from a checkout of the project it names, and anywhere else it reads no settings file and records 0. From a managed checkout whose `baley.toml` yields no id (it cannot be read, does not parse, or holds no valid id), `purge` refuses with `config-unavailable`, naming the file and saying that a purge run outside a checkout of the project records policy version 0. A `baley config set` outside any project, one in a project that is not in this machine's ledger, and one that changes nothing run no step. The first two give 0, since no recorded policy applies, and one that changes nothing gives the version in force. `baley config interview` applies settings through the apply function shared with `config set`, bound to the shown path and its expected digest or absence, with the same policy step. T16 carries forward only its own preceding reviewer-list write's digest when that write shares the target ([0003 section 5](0003-configuration-and-routing.md#5-commands-and-operations)). Its provider acknowledgement is a separate per-user record with policy version 0. A declined or all-blank interview opens no store and records no acknowledgement ([0003](0003-configuration-and-routing.md), CFG-R28). +The policy step runs before every command that appends to a project's chain from a checkout, and a command that appends to no chain runs no step. It re-reads both files and is a command of its own, `policy.record` by actor `baley`, and it opens no command when nothing changed. The command line's step is keyed by the checkout and no host. The server's step is keyed by the host, records the policy with that host's sections applied, carries the call's caller, and returns the version its write records on its own command. A server read runs no step, and neither does a write whose request the ledger already holds. Checkout admission, described above, runs before the step for every command that runs one. Records in the per-user project `user` carry 0. Seeding and detection build no policy, and the guard builds one only to judge a commit, keeping from it only the denials it remembers (`guard.policy_recorded`), never a `policy.effective`. `purge` is the one chain-writing command that may run outside a checkout: it runs the step only from a checkout of the project it names, and anywhere else it reads no settings file and records 0. From a managed checkout whose `baley.toml` yields no id (it cannot be read, does not parse, or holds no valid id), `purge` refuses with `config-unavailable`, naming the file and saying that a purge run outside a checkout of the project records policy version 0. A `baley config set` outside any project, one in a project that is not in this machine's ledger, and one that changes nothing run no step. The first two give 0, since no recorded policy applies, and one that changes nothing gives the version in force. `baley config interview` applies settings through the apply function shared with `config set`, bound to the shown path and its expected digest or absence, with the same policy step. When its reviewer-list write replaced the same global file, the digest of the bytes that write returned is the expected digest for the settings write, and the apply function reads the file again so the list survives ([0003 section 5](0003-configuration-and-routing.md#5-commands-and-operations)). Its provider acknowledgement is a separate per-user record with policy version 0, and its host step writes Claude Code's settings file and runs no policy step. A step that fails stops the run, and the report names the writes already completed. A declined interview, and one with nothing to change, open no store and record no acknowledgement ([0003](0003-configuration-and-routing.md), CFG-R28). Each checkout runs under the project file committed at its own HEAD; if two checkouts of one project run under different policies, Hardin reports the divergence and names both. The project id is taken from the working-tree `baley.toml`, so a new project is managed before its first commit, while the project's settings come from the copy committed at HEAD. An unborn HEAD, or a project file not yet committed, gives an empty project layer; any other failure to read HEAD's copy refuses with `config-unavailable`, naming the file. When the working-tree file differs from HEAD's copy, or is not yet committed, `baley config show` and `baley doctor` show a diagnostic that its changes apply once committed; the policy step itself prints nothing. That diagnostic is a note about the file, not part of the effective policy Baley records, so an uncommitted edit is not a policy change, and `baley config set` on the project file likewise says the change applies once committed. The routing-admission rule is kept: a dispatch whose routing inputs changed since admission is refused. @@ -1262,7 +1262,7 @@ The domain rules are owned, specified and tested by the area design documents ([ See [Threat model](#threat-model) for who is defended against. -- **Agent isolation (EVD-R24).** Agents run as the owner's user, so file modes cannot keep them out. The barrier is Claude Code's, and three mechanisms carry it: the sandbox, which denies shell commands and their child processes reads and writes of Baley's home and its config folder; the `Read` and `Edit` deny rules, which cover its built-in file tools; and the guard's refusal of Read, Grep and Glob calls into either folder. Claude Code applies `Read` rules to Grep and Glob only on a best-effort basis, which is why the guard refusal is needed. An unavailable sandbox is reported, never passed over. The owner's own Claude Code settings can loosen the barrier, and `baley doctor` judges the settings and hook documents it is given and reports what they configure for an agent to reach, as configuration and not as proof of enforcement (`crates/baley/src/host_doctor/protection.rs:48-92`). It reads the install record to tell an artifact that was installed from one that never was: on a machine where `baley install` never ran it reports every artifact as not installed, and after an install it judges the placed settings and hook. The settings content that carries the sandbox and the deny rules, and the per-tool coverage judgement over a settings document, are rendered in the library (`crates/baley/src/host_artifacts/security.rs:143-222`, `crates/baley/src/host_artifacts/coverage.rs:281-356`); `baley install` applies the content by merging it into the user settings file under the Claude Code configuration folder, where a project's own settings can still override a single value such as `sandbox.enabled`, and the 2026-10-08 runs measured what Claude Code 2.1.294 does with it (see the host matrix below, first sheet `claude-live-2026-10-08-run1.md#bar.home.bash-read`). Those runs used the rendered content, and a session loading the file `baley install` itself wrote is observed by T17. With these in place, the ledger and global settings are out of an agent's reach. API keys stay in the owner's environment, outside Baley's folders and outside this protection ([0003](0003-configuration-and-routing.md), CFG-R24, [ADR 0039](../adr/0039-session-owned-provider-credentials.md)). Baley does not claim to hide the ledger from a process running as the owner outside the host. What Baley guarantees whether or not reads are denied: writes are refused, tampering is detected by the chain and the anchors, Baley records no credential values by design, sensitive returned content is kept in payloads, and payloads can be purged. The 2026-09-25 host matrix showed this for a stand-in home through Bash: the agent was blocked from reading and writing while Baley's server and hook still wrote. The 2026-10-08 runs of the built binary added the config folder and the built-in file tools. Both folders behaved alike through Bash and its children, Read, Write and Edit naming a file there were refused by the deny rules before the hook ran, and Grep and Glob are not in Claude Code 2.1.294's default tool list, so those two runs could not test them (`claude-live-2026-10-08-run1.md#bar.config.bash-read`, `#bar.config.read-tool`, `#bar.config.write-tool`). The third run launched a session with `--allowedTools Grep,Glob`, which offers them. The guard refused both tools over each folder itself and over its parent, for the home and for the config folder, and nothing of the folder came back (`claude-live-2026-10-08-run3.md#bar.home.grep-folder`, `#bar.home.grep-parent`, `#bar.home.glob-folder`, `#bar.home.glob-parent`, `#bar.config.grep-folder`, `#bar.config.grep-parent`, `#bar.config.glob-folder`, `#bar.config.glob-parent`). Over the folder itself the guard's refusal came before the best-effort `Read` rule answered, so that rule's own reach over Grep and Glob was not measured and stays unverified. The guard also refuses file-tool writes into the home and the config folder, to the stubs, the settings file, the registration file and the executable that install places, and into the versions folder, as a best-effort layer ([0010](0010-guard.md) section 5). In the runs the deny rules refused those writes first, so the guard's refusal was not reached. +- **Agent isolation (EVD-R24).** Agents run as the owner's user, so file modes cannot keep them out. The barrier is Claude Code's, and three mechanisms carry it: the sandbox, which denies shell commands and their child processes reads and writes of Baley's home and its config folder; the `Read` and `Edit` deny rules, which cover its built-in file tools; and the guard's refusal of Read, Grep and Glob calls into either folder. Claude Code applies `Read` rules to Grep and Glob only on a best-effort basis, which is why the guard refusal is needed. An unavailable sandbox is reported, never passed over. The owner's own Claude Code settings can loosen the barrier, and `baley doctor` judges the settings and hook documents it is given and reports what they configure for an agent to reach, as configuration and not as proof of enforcement (`crates/baley/src/host_doctor/protection.rs:48-92`). It reads the install record to tell an artifact that was installed from one that never was: on a machine where `baley install` never ran it reports every artifact as not installed, and after an install it judges the placed settings and hook. The settings content that carries the sandbox and the deny rules, and the per-tool coverage judgement over a settings document, are rendered in the library (`crates/baley/src/host_artifacts/security.rs:144-223`, `crates/baley/src/host_artifacts/coverage.rs:281-356`); `baley install` applies the content by merging it into the user settings file under the Claude Code configuration folder, where a project's own settings can still override a single value such as `sandbox.enabled`, and the 2026-10-08 runs measured what Claude Code 2.1.294 does with it (see the host matrix below, first sheet `claude-live-2026-10-08-run1.md#bar.home.bash-read`). Those runs used the rendered content, and a session loading the file `baley install` itself wrote is observed by T17. With these in place, the ledger and global settings are out of an agent's reach. API keys stay in the owner's environment, outside Baley's folders and outside this protection ([0003](0003-configuration-and-routing.md), CFG-R24, [ADR 0039](../adr/0039-session-owned-provider-credentials.md)). Baley does not claim to hide the ledger from a process running as the owner outside the host. What Baley guarantees whether or not reads are denied: writes are refused, tampering is detected by the chain and the anchors, Baley records no credential values by design, sensitive returned content is kept in payloads, and payloads can be purged. The 2026-09-25 host matrix showed this for a stand-in home through Bash: the agent was blocked from reading and writing while Baley's server and hook still wrote. The 2026-10-08 runs of the built binary added the config folder and the built-in file tools. Both folders behaved alike through Bash and its children, Read, Write and Edit naming a file there were refused by the deny rules before the hook ran, and Grep and Glob are not in Claude Code 2.1.294's default tool list, so those two runs could not test them (`claude-live-2026-10-08-run1.md#bar.config.bash-read`, `#bar.config.read-tool`, `#bar.config.write-tool`). The third run launched a session with `--allowedTools Grep,Glob`, which offers them. The guard refused both tools over each folder itself and over its parent, for the home and for the config folder, and nothing of the folder came back (`claude-live-2026-10-08-run3.md#bar.home.grep-folder`, `#bar.home.grep-parent`, `#bar.home.glob-folder`, `#bar.home.glob-parent`, `#bar.config.grep-folder`, `#bar.config.grep-parent`, `#bar.config.glob-folder`, `#bar.config.glob-parent`). Over the folder itself the guard's refusal came before the best-effort `Read` rule answered, so that rule's own reach over Grep and Glob was not measured and stays unverified. The guard also refuses file-tool writes into the home and the config folder, to the stubs, the settings file, the registration file and the executable that install places, and into the versions folder, as a best-effort layer ([0010](0010-guard.md) section 5). In the runs the deny rules refused those writes first, so the guard's refusal was not reached. - **Detection.** Anchors on the forge make a rewrite, truncation or rollback detectable up to the latest anchor, even by an agent that can run `sqlite3` and sign with the owner's key. - **Secrets.** Test output and review material can carry secrets. They are always payloads, so they can always be purged, with the limits stated in [Payloads, retention and purge](#payloads-retention-and-purge-evd-r11-evd-r14). Baley reads no API key and cannot promise to recognize or scrub one a command prints into returned content. Every program Claude Code starts can see keys in the environment, agents and subagents included. Code sent for review goes to the chosen provider under its terms (CFG-R28). - **Network.** The store makes no network calls. Anchors are pushed with git to the project's own remote, and the latest anchor is fetched from it, through the core's forge seam; the binary implements that seam, and the store only receives the fetched anchor. A remote is named in the ledger by its configured name, never by URL or credentials. Git runs without prompts. The remote is the project's `git.remote`, from the committed `baley.toml`, and must be configured in the repository at the current directory. @@ -1381,7 +1381,7 @@ Claude Code is the only supported host. A host is added when the matrix shows th | Project discovery from the working directory | Hook and MCP server start in the session's directory, which may be a subdirectory, and Claude Code gives both `CLAUDE_PROJECT_DIR` | Shown on 2026-09-25, and again on 2026-10-02 for a stdio server: Claude Code starts the MCP server in the session's directory. Its documentation does not promise that directory, so the server takes its project from `CLAUDE_PROJECT_DIR` and records its working directory beside it, rather than binding the directory it starts in. The hook and the server both walk up from `CLAUDE_PROJECT_DIR`, and the hook keeps its working directory for the paths a tool names and the branch it reads (Build 3). Measured on 2026-10-08 against the built binary through an explicit `--mcp-config` launch: `CLAUDE_PROJECT_DIR` and the server's recorded working directory were both the session's project folder, and for a session started in the project's `sub` folder both were that `sub` folder, read from `project_directory` and `working_directory` on the server callers in the ledger (first sheet `claude-live-2026-10-08-run1.md#id.explicit.startup`, `#id.two-sessions`, and the second run's reads in `claude-live-2026-10-08-run2.md#post-run-reads`). The server started in `sub` bound the project found through the nearer `baley.toml` (`claude-live-2026-10-08-run1.md#hand.nearer-file`). The first two runs made no login into an isolated configuration, so the user scope was not measured in them. The third run did, with the owner's login: a session started in the project's `sub` folder with no `--mcp-config`, whose `/mcp` panel listed `baley` from the user scope, had `CLAUDE_PROJECT_DIR` and the server's recorded working directory both equal to that `sub` folder, read from the server caller in the ledger (`claude-live-2026-10-08-run3.md#id.user-scope.startup`, `#ses.b.registered`, `#ses.b.login`). Its explicit launch again gave the project folder for both (`claude-live-2026-10-08-run3.md#id.explicit.startup`). | | Hook contract (tool names, event names, answer format) | `PreToolUse` with the matcher `Bash\|Monitor\|PowerShell\|Read\|Grep\|Glob\|Write\|Edit\|NotebookEdit`, answering `ask` and `deny` in `hookSpecificOutput`, and a pass with no output | Shown for Bash on 2026-09-25: the hook's input needs no adapter. The answer fields follow Claude Code's hooks reference. The library renders the hook with this matcher (`crates/baley/src/host_artifacts/hook.rs:13-37`). Measured on 2026-10-08 with the built hook on Claude Code 2.1.294 (first sheet `claude-live-2026-10-08-run1.md#ctl.hook.bash`, `#ctl.hook.monitor`, `#ctl.hook.read`): it fired for Bash, Monitor and Read. Claude Code obeyed `deny` for a commit on `main` through Bash and through Monitor, and again through Bash in the second run (`claude-live-2026-10-08-run1.md#ctl.commit-main-bash`, `#ctl.commit-main-monitor`, `claude-live-2026-10-08-run2.md#ses.a.smoke-commit`). It obeyed `ask` for a push through both: a yes ran the push and a no left the remote's refs unchanged (`claude-live-2026-10-08-run1.md#ctl.push-bash-yes`, `#ctl.push-bash-no`, `#ctl.push-monitor-yes`, `#ctl.push-monitor-no`). A pass with no output let a commit with shell structure the scanner declines run (`claude-live-2026-10-08-run1.md#ctl.declined-syntax`). In the first run, Write, Edit and NotebookEdit calls into the two folders never reached the hook, because Claude Code's deny rules and its read-first check refuse them first (`claude-live-2026-10-08-run1.md#ctl.hook.write`, `#ctl.hook.edit`, `#ctl.hook.notebookedit`). In the third run the same three tools were called on unprotected files in the project, and the hook fired once for each, the guard answered none, and the file followed each call (`claude-live-2026-10-08-run3.md#ctl.hook.write`, `#ctl.hook.edit`, `#ctl.hook.notebookedit`). Grep and Glob are not in Claude Code 2.1.294's default tool list, so the first two runs had no hook line for them. In the third run, in a session launched with `--allowedTools Grep,Glob`, the hook fired four times for each and the guard denied all eight calls, and the guard's highest time in that run, for any tool, was 49 ms (`claude-live-2026-10-08-run3.md#ctl.hook.grep`, `#ctl.hook.glob`, `#ctl.latency`). `pwsh` was absent in every run, so the hook on PowerShell is unverified (`claude-live-2026-10-08-run1.md#ctl.hook.powershell`, `claude-live-2026-10-08-run3.md#ctl.hook.powershell`). The `tool_input` field names were read from the stand-in probe's hook input (`claude-live-2026-10-08-run1.md#fld.bash`, `#fld.monitor`, `#fld.monitor-watch`, `#fld.read`, `#fld.write`, `#fld.edit`, `#fld.notebookedit`), and for Grep (`pattern`, `path`) and Glob (`pattern`, `path`) from a stand-in session launched with `--allowedTools Grep,Glob` (`claude-live-2026-10-08-run3.md#fld.grep`, `#fld.glob`). The `/hooks` panel does not list a hook given through `--settings`, though the hook fires (`claude-live-2026-10-08-run1.md#ses.a.panels`). | | Re-delivered tool calls | Retries after timeout | The hook answers a call whose host, session and `tool_use_id` it already answered from the `guard` view in `user`, when the input digest, project directory and working directory match, with no git or policy read. A reused call id with other input is answered on its own and is unrecordable, so its ask is denied ([0010](0010-guard.md), GRD-R9, GRD-R10). No `tool_use_id` arrived twice in either 2026-10-08 run, so a live redelivery was not observed and stays unverified (`claude-live-2026-10-08-run1.md#ctl.redelivery`, and the timing read in `claude-live-2026-10-08-run2.md#post-run-reads`). Proven for MCP calls in Build 1, where the `request` view lands. | -| Access to Baley's home and config folder | Server and hook write. Agents are denied reads and writes of both | The home was shown through Bash on 2026-09-25: a denied read looks like a missing file, a denied write exits 0 and nothing lands. Measured on 2026-10-08 against the built binary for the config folder as well as the home, and measured again before each release. Through Bash, a `sh -c` child and a script child, a denied read looked like a missing file and a denied write exited 0 with nothing landing (first sheet `claude-live-2026-10-08-run1.md#bar.home.bash-read`, `#bar.home.bash-write`, `#bar.config.bash-read`, `#bar.config.bash-write`, and the child and script rows beside them). Claude Code's own permission check refused a Monitor command that named a file in either folder, and a child script that Monitor ran was sandboxed (`claude-live-2026-10-08-run1.md#bar.home.monitor-read`, `#bar.home.monitor-child-read`). Read, Write and Edit naming a file there were refused by the deny rules before the hook (`claude-live-2026-10-08-run1.md#bar.home.read-tool`, `#bar.home.write-tool`, `#bar.home.edit-tool`, and the same rows for the config folder). NotebookEdit was refused by the read-first check, so the Edit rule's reach over it is not shown (`claude-live-2026-10-08-run1.md#bar.home.notebook-edit`). Grep and Glob over the folder itself are labelled best-effort. They are not in Claude Code 2.1.294's default tool list, so the first two runs could not test them. In the third run, in a session launched with `--allowedTools Grep,Glob`, the guard refused both tools over each folder itself and over its parent, for the home and for the config folder, and nothing of the folder came back (`claude-live-2026-10-08-run3.md#bar.home.grep-folder`, `#bar.home.grep-parent`, `#bar.home.glob-folder`, `#bar.home.glob-parent`, `#bar.config.grep-folder`, `#bar.config.grep-parent`, `#bar.config.glob-folder`, `#bar.config.glob-parent`). Over the folder itself the guard answered before the `Read` rule, so that rule's own reach over Grep and Glob was not measured. The PowerShell rows are unverified because `pwsh` was absent (`claude-live-2026-10-08-run1.md#bar.home.ps-read`, `claude-live-2026-10-08-run3.md#bar.home.ps-read`). Without `bwrap` and `socat` Claude Code refused to start the session (`claude-live-2026-10-08-run1.md#prot.absent-sandbox`), and the server and the hook wrote the denied home in all three runs (`claude-live-2026-10-08-run1.md#prot.server-writes-home`, `#prot.hook-writes-home`, `claude-live-2026-10-08-run2.md#prot.server-writes-home`, `claude-live-2026-10-08-run3.md#prot.server-writes-home`, `#prot.hook-writes-home`). The sandbox and deny-rule content for both folders is rendered in the library, and the coverage judge reports from a settings document which of the three mechanisms covers each tool's reads and writes (`crates/baley/src/host_artifacts/security.rs:143-222`, `crates/baley/src/host_artifacts/coverage.rs:281-356`). The doctor runs that judge over the documents it is given and words each verdict as configuration, not as enforcement (`crates/baley/src/host_doctor/protection.rs:48-92`, `crates/baley/src/host_doctor/report.rs:265-322`). | +| Access to Baley's home and config folder | Server and hook write. Agents are denied reads and writes of both | The home was shown through Bash on 2026-09-25: a denied read looks like a missing file, a denied write exits 0 and nothing lands. Measured on 2026-10-08 against the built binary for the config folder as well as the home, and measured again before each release. Through Bash, a `sh -c` child and a script child, a denied read looked like a missing file and a denied write exited 0 with nothing landing (first sheet `claude-live-2026-10-08-run1.md#bar.home.bash-read`, `#bar.home.bash-write`, `#bar.config.bash-read`, `#bar.config.bash-write`, and the child and script rows beside them). Claude Code's own permission check refused a Monitor command that named a file in either folder, and a child script that Monitor ran was sandboxed (`claude-live-2026-10-08-run1.md#bar.home.monitor-read`, `#bar.home.monitor-child-read`). Read, Write and Edit naming a file there were refused by the deny rules before the hook (`claude-live-2026-10-08-run1.md#bar.home.read-tool`, `#bar.home.write-tool`, `#bar.home.edit-tool`, and the same rows for the config folder). NotebookEdit was refused by the read-first check, so the Edit rule's reach over it is not shown (`claude-live-2026-10-08-run1.md#bar.home.notebook-edit`). Grep and Glob over the folder itself are labelled best-effort. They are not in Claude Code 2.1.294's default tool list, so the first two runs could not test them. In the third run, in a session launched with `--allowedTools Grep,Glob`, the guard refused both tools over each folder itself and over its parent, for the home and for the config folder, and nothing of the folder came back (`claude-live-2026-10-08-run3.md#bar.home.grep-folder`, `#bar.home.grep-parent`, `#bar.home.glob-folder`, `#bar.home.glob-parent`, `#bar.config.grep-folder`, `#bar.config.grep-parent`, `#bar.config.glob-folder`, `#bar.config.glob-parent`). Over the folder itself the guard answered before the `Read` rule, so that rule's own reach over Grep and Glob was not measured. The PowerShell rows are unverified because `pwsh` was absent (`claude-live-2026-10-08-run1.md#bar.home.ps-read`, `claude-live-2026-10-08-run3.md#bar.home.ps-read`). Without `bwrap` and `socat` Claude Code refused to start the session (`claude-live-2026-10-08-run1.md#prot.absent-sandbox`), and the server and the hook wrote the denied home in all three runs (`claude-live-2026-10-08-run1.md#prot.server-writes-home`, `#prot.hook-writes-home`, `claude-live-2026-10-08-run2.md#prot.server-writes-home`, `claude-live-2026-10-08-run3.md#prot.server-writes-home`, `#prot.hook-writes-home`). The sandbox and deny-rule content for both folders is rendered in the library, and the coverage judge reports from a settings document which of the three mechanisms covers each tool's reads and writes (`crates/baley/src/host_artifacts/security.rs:144-223`, `crates/baley/src/host_artifacts/coverage.rs:281-356`). The doctor runs that judge over the documents it is given and words each verdict as configuration, not as enforcement (`crates/baley/src/host_doctor/protection.rs:48-92`, `crates/baley/src/host_doctor/report.rs:265-322`). | | Store unreachable | The decision is unrecordable (GRD-R9): an ask is denied, a deny stands and a pass on failure stays a loud pass. A plain pass needs no store | Judged in tests from the store's busy and needs-rebuild answers and from a transaction that fails after its answer was staged. The hook's times were measured on 2026-10-08 while other sessions exited: guard calls took 46 to 47 ms in the first run and 45 to 47 ms in the second (54 to 58 ms with the wrapper that timed them), and the highest guard time of each run was 48 ms and 49 ms, far below the host's 10-second timeout (`claude-live-2026-10-08-run1.md#ctl.contention-exit`, `#ctl.latency`, `claude-live-2026-10-08-run2.md#ctl.contention-exit`, and the timing read in `claude-live-2026-10-08-run2.md#post-run-reads`). No call reached the timeout, so what Claude Code does with a hook that times out is unverified (`claude-live-2026-10-08-run1.md#ctl.timeout-not-denial`). | | Skill loaded from an uncommitted folder (ADR 0009) | A rendered stub placed as `SKILL.md` in an uncommitted skills folder is expected to be listed and to run `baley_query` with per-call approval | Shown on 2026-10-08 in the third run, in both placements. The stub was placed as `claude-config/skills/bal-help/SKILL.md` in the isolated configuration, and the first run's write rows read it back unchanged (`claude-live-2026-10-08-run1.md#prot.stub.write-tool`, `#prot.stub.bash-write`). Session B, after the owner's login, listed `bal-help` as a user skill, and running it called `baley_query` for the instruction and then for help, each call with its own permission prompt (`claude-live-2026-10-08-run3.md#hand.skill-listed`, `#hand.skill-run`). The same stub placed uncommitted at the disposable project's `.claude/skills/bal-help/SKILL.md` was listed as a project skill and ran the same way, and the folder was removed afterwards (`claude-live-2026-10-08-run3.md#hand.skill-project-listed`, `#hand.skill-project-run`). The question ADR 0009 left open, whether a skill loads from a rendered, uncommitted folder, is answered yes for both placements on Claude Code 2.1.294. | From c8457eee02d9fc0ece3ae81e685daf325e083c69 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 20:51:22 -0400 Subject: [PATCH 24/27] docs(design): show the interview's host and reviewer-list writes in the C4 model Two relations changed: the command line now adds API hosts to Claude Code's settings file and writes the global settings file whole for the reviewer list. The exported blocks and their captions follow the model. --- docs/design/0001-evidence-ledger.md | 4 ++-- docs/design/0002-system-design.md | 16 ++++++++-------- docs/design/0003-configuration-and-routing.md | 6 +++--- docs/design/c4/workspace.dsl | 6 +++--- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/design/0001-evidence-ledger.md b/docs/design/0001-evidence-ledger.md index ae1be793..ca622cc4 100644 --- a/docs/design/0001-evidence-ledger.md +++ b/docs/design/0001-evidence-ledger.md @@ -143,7 +143,7 @@ graph LR 1-. "

Uses the command line
" .->2 14-. "
Work orders, results,
questions
[MCP over stdio]
" .->2 14-. "
Asks before each tool call
runs
[Pre-tool hook, one process per tool call]
" .->2 - 2-. "
Writes the wiring with baley
install: MCP registration,
pre-tool hook, sandbox and
deny settings, skill stubs
[claude mcp add-json, files under the Claude folder]
" .->14 + 2-. "
Writes the wiring with baley
install: MCP registration,
pre-tool hook, sandbox and
deny settings, skill stubs.
Adds the chosen providers'
API hosts to the sandbox
allow list with baley config
interview
[claude mcp add-json, files under the Claude folder]
" .->14 14-. "
Workers edit source and
commit
" .->15 14-. "
Review and model-list API
calls with the owner's
environment keys
" .->18 2-. "
Reads git facts, runs tests
and git
" .->15 @@ -154,7 +154,7 @@ graph LR ``` -*Figure 1. System context from the shared C4 model. The host reaches Baley over MCP and its pre-tool hook. `baley install` writes the host's wiring: the MCP registration through `claude`, the pre-tool hook, the sandbox and deny settings in the user settings file and the skill stubs. The session makes review and model-list API calls with the owner's environment keys; Baley receives their results. The foreground `baley update` command and the detached opt-in updater are built and contact the release source. `baley serve` starts the detached check only when `updates.auto` is on, without waiting for it. T14 and T17 use unsigned development artifacts only, and #14 supplies release verification.* +*Figure 1. System context from the shared C4 model. The host reaches Baley over MCP and its pre-tool hook. `baley install` writes the host's wiring: the MCP registration through `claude`, the pre-tool hook, the sandbox and deny settings in the user settings file and the skill stubs. `baley config interview` adds the chosen providers' API hosts to the same settings file. The session makes review and model-list API calls with the owner's environment keys; Baley receives their results. The foreground `baley update` command and the detached opt-in updater are built and contact the release source. `baley serve` starts the detached check only when `updates.auto` is on, without waiting for it. T14 and T17 use unsigned development artifacts only, and #14 supplies release verification.* ```mermaid flowchart TB diff --git a/docs/design/0002-system-design.md b/docs/design/0002-system-design.md index 8584ad59..42804d2a 100644 --- a/docs/design/0002-system-design.md +++ b/docs/design/0002-system-design.md @@ -74,7 +74,7 @@ graph LR 1-. "
Uses the command line
" .->2 14-. "
Work orders, results,
questions
[MCP over stdio]
" .->2 14-. "
Asks before each tool call
runs
[Pre-tool hook, one process per tool call]
" .->2 - 2-. "
Writes the wiring with baley
install: MCP registration,
pre-tool hook, sandbox and
deny settings, skill stubs
[claude mcp add-json, files under the Claude folder]
" .->14 + 2-. "
Writes the wiring with baley
install: MCP registration,
pre-tool hook, sandbox and
deny settings, skill stubs.
Adds the chosen providers'
API hosts to the sandbox
allow list with baley config
interview
[claude mcp add-json, files under the Claude folder]
" .->14 14-. "
Workers edit source and
commit
" .->15 14-. "
Review and model-list API
calls with the owner's
environment keys
" .->18 2-. "
Reads git facts, runs tests
and git
" .->15 @@ -85,7 +85,7 @@ graph LR ``` -*Figure 1. System context. The host reaches Baley two ways: over MCP from its session, and through its pre-tool hook before each tool call runs. `baley install` writes the host's wiring: the MCP registration through `claude`, the pre-tool hook, the sandbox and deny settings in the user settings file and the skill stubs. The session sends review and model-list requests to providers. The foreground `baley update` command and the detached opt-in updater are built and contact the release source. `baley serve` starts the detached check only when `updates.auto` is on, without waiting for it. T14 and T17 use unsigned development artifacts only, and #14 supplies release verification.* +*Figure 1. System context. The host reaches Baley two ways: over MCP from its session, and through its pre-tool hook before each tool call runs. `baley install` writes the host's wiring: the MCP registration through `claude`, the pre-tool hook, the sandbox and deny settings in the user settings file and the skill stubs. `baley config interview` adds the chosen providers' API hosts to the same settings file. The session sends review and model-list requests to providers. The foreground `baley update` command and the detached opt-in updater are built and contact the release source. `baley serve` starts the detached check only when `updates.auto` is on, without waiting for it. T14 and T17 use unsigned development artifacts only, and #14 supplies release verification.* | Actor | What it is | Its part | |---|---|---| @@ -190,10 +190,10 @@ graph LR 1-. "
Uses the command line
" .->3 14-. "
Work orders, results,
questions
[MCP over stdio]
" .->3 14-. "
Asks before each tool call
runs
[Pre-tool hook, one process per tool call]
" .->3 - 3-. "
Writes the wiring with baley
install: MCP registration,
pre-tool hook, sandbox and
deny settings, skill stubs
[claude mcp add-json, files under the Claude folder]
" .->14 + 3-. "
Writes the wiring with baley
install: MCP registration,
pre-tool hook, sandbox and
deny settings, skill stubs.
Adds the chosen providers'
API hosts to the sandbox
allow list with baley config
interview
[claude mcp add-json, files under the Claude folder]
" .->14 14-. "
Workers edit source and
commit
" .->15 14-. "
Review and model-list API
calls with the owner's
environment keys
" .->18 - 3-. "
Reads, and writes a file
whole for config set
" .->13 + 3-. "
Reads, and writes a file
whole for config set and for
the interview's reviewer list
" .->13 3-. "
Appends events, reads views
" .->12 3-. "
Reads git facts, runs tests
and git
" .->15 3-. "
Pushes anchors, opens pull
requests and issues
" .->17 @@ -206,7 +206,7 @@ graph LR ``` -*Figure 3. Containers: the Baley server, one process per session over MCP and one per tool call through the pre-tool hook, with the ledger and the settings files, plus the detached updater. The updater opens the same per-user store through the store port to claim and record at most one check per UTC day per installation. Provider calls and credentials stay with the host session. Manual `baley update` waits for its downloads and receipt in the foreground; only checks started by `baley serve` when `updates.auto` is on run in the detached updater. Both update paths are built and use the binary's HTTP client. T14 and T17 use unsigned development artifacts only; #14 supplies signed checksum manifests and release verification. The guard hook records its answers in the per-user ledger ([0010](0010-guard.md)). `baley install` writes the host's wiring: the MCP registration through `claude`, the pre-tool hook, the sandbox and deny settings in the user settings file and the skill stubs.* +*Figure 3. Containers: the Baley server, one process per session over MCP and one per tool call through the pre-tool hook, with the ledger and the settings files, plus the detached updater. The binary writes Baley's global settings file whole for `config set` and for the interview's reviewer list. The updater opens the same per-user store through the store port to claim and record at most one check per UTC day per installation. Provider calls and credentials stay with the host session. Manual `baley update` waits for its downloads and receipt in the foreground; only checks started by `baley serve` when `updates.auto` is on run in the detached updater. Both update paths are built and use the binary's HTTP client. T14 and T17 use unsigned development artifacts only; #14 supplies signed checksum manifests and release verification. The guard hook records its answers in the per-user ledger ([0010](0010-guard.md)). `baley install` writes the host's wiring: the MCP registration through `claude`, the pre-tool hook, the sandbox and deny settings in the user settings file and the skill stubs. `baley config interview` adds the chosen providers' API hosts to the same settings file.* ```mermaid @@ -262,14 +262,14 @@ graph LR 1-. "
Uses the command line
" .->4 14-. "
Work orders, results,
questions
[MCP over stdio]
" .->4 14-. "
Asks before each tool call
runs
[Pre-tool hook, one process per tool call]
" .->4 - 4-. "
Writes the wiring with baley
install: MCP registration,
pre-tool hook, sandbox and
deny settings, skill stubs
[claude mcp add-json, files under the Claude folder]
" .->14 + 4-. "
Writes the wiring with baley
install: MCP registration,
pre-tool hook, sandbox and
deny settings, skill stubs.
Adds the chosen providers'
API hosts to the sandbox
allow list with baley config
interview
[claude mcp add-json, files under the Claude folder]
" .->14 14-. "
Workers edit source and
commit
" .->15 4-. "
Asks what may happen next
" .->5 5-. "
Applies the area's rules
" .->6 6-. "
Requests work orders
" .->7 7-. "
Resolves role, model and
effort
" .->8 8-. "
Reads
" .->13 - 4-. "
Writes a settings file whole,
for config set
" .->13 + 4-. "
Writes a settings file whole,
for config set and for the
interview's reviewer list
" .->13 8-. "
Checks model names
" .->9 8-. "
Records the effective policy
and each route
" .->10 4-. "
Settings commands
" .->8 @@ -288,7 +288,7 @@ graph LR ``` -*Figure 4. Components inside the Baley server, the binary each session starts over stdio and the pre-tool hook starts for each tool call. The model catalog reads imported lists and has no provider connection or key reader. The host interface starts the detached updater at `baley serve` startup only when `updates.auto` is on, without waiting for the child or the network. The updater opens the same per-user ledger through the store port to claim and record at most one check per UTC day per installation. The host interface also runs manual `baley update` in the foreground, waiting for the release source and its receipt. Both update paths are built for unsigned development artifacts only until #14 supplies signed checksum manifests and release verification. Figure 3 shows the updater's download path. The host interface also runs `baley install`, which writes the host's wiring, seeds the catalog and records `install.recorded` in the per-user ledger.* +*Figure 4. Components inside the Baley server, the binary each session starts over stdio and the pre-tool hook starts for each tool call. The model catalog reads imported lists and has no provider connection or key reader. The host interface starts the detached updater at `baley serve` startup only when `updates.auto` is on, without waiting for the child or the network. The updater opens the same per-user ledger through the store port to claim and record at most one check per UTC day per installation. The host interface also runs manual `baley update` in the foreground, waiting for the release source and its receipt. Both update paths are built for unsigned development artifacts only until #14 supplies signed checksum manifests and release verification. Figure 3 shows the updater's download path. The host interface also runs `baley install`, which writes the host's wiring, seeds the catalog and records `install.recorded` in the per-user ledger. The interview adds the chosen providers' API hosts to Claude Code's settings file, and writes the global reviewer list whole, as `config set` writes a settings file.* | Component | Responsibility | |---|---| diff --git a/docs/design/0003-configuration-and-routing.md b/docs/design/0003-configuration-and-routing.md index 5a59f0a3..bcf544c0 100644 --- a/docs/design/0003-configuration-and-routing.md +++ b/docs/design/0003-configuration-and-routing.md @@ -68,11 +68,11 @@ graph LR 1-. "
Uses the command line
" .->4 14-. "
Work orders, results,
questions
[MCP over stdio]
" .->4 14-. "
Asks before each tool call
runs
[Pre-tool hook, one process per tool call]
" .->4 - 4-. "
Writes the wiring with baley
install: MCP registration,
pre-tool hook, sandbox and
deny settings, skill stubs
[claude mcp add-json, files under the Claude folder]
" .->14 + 4-. "
Writes the wiring with baley
install: MCP registration,
pre-tool hook, sandbox and
deny settings, skill stubs.
Adds the chosen providers'
API hosts to the sandbox
allow list with baley config
interview
[claude mcp add-json, files under the Claude folder]
" .->14 14-. "
Review and model-list API
calls with the owner's
environment keys
" .->18 7-. "
Resolves role, model and
effort
" .->8 8-. "
Reads
" .->13 - 4-. "
Writes a settings file whole,
for config set
" .->13 + 4-. "
Writes a settings file whole,
for config set and for the
interview's reviewer list
" .->13 8-. "
Checks model names
" .->9 8-. "
Records the effective policy
and each route
" .->10 4-. "
Settings commands
" .->8 @@ -84,7 +84,7 @@ graph LR ``` -*Figure 1. Policy and the model catalog inside Baley. The host session calls providers with environment credentials and hands list responses to Baley for parsing. The host interface also runs `baley install`, which writes Claude Code's wiring and seeds the catalog without a list call.* +*Figure 1. Policy and the model catalog inside Baley. The host session calls providers with environment credentials and hands list responses to Baley for parsing. The host interface also runs `baley install`, which writes Claude Code's wiring and seeds the catalog without a list call. The interview adds the chosen providers' API hosts to Claude Code's settings file, and writes the global reviewer list whole, as `config set` writes a settings file.* ## 2. Terms diff --git a/docs/design/c4/workspace.dsl b/docs/design/c4/workspace.dsl index 95ee6ea2..96ba5c06 100644 --- a/docs/design/c4/workspace.dsl +++ b/docs/design/c4/workspace.dsl @@ -34,7 +34,7 @@ workspace "Baley" "The C4 model behind Baley's design documents. Every structure host -> binary "Asks before each tool call runs" "Pre-tool hook, one process per tool call" # The only relation from a component to the host, so the context and # container views show it as an implied edge with this text. - hostInterface -> host "Writes the wiring with baley install: MCP registration, pre-tool hook, sandbox and deny settings, skill stubs" "claude mcp add-json, files under the Claude folder" + hostInterface -> host "Writes the wiring with baley install: MCP registration, pre-tool hook, sandbox and deny settings, skill stubs. Adds the chosen providers' API hosts to the sandbox allow list with baley config interview" "claude mcp add-json, files under the Claude folder" host -> repo "Workers edit source and commit" host -> reviewers "Review and model-list API calls with the owner's environment keys" hostInterface -> hardin "Asks what may happen next" @@ -43,9 +43,9 @@ workspace "Baley" "The C4 model behind Baley's design documents. Every structure composer -> policy "Resolves role, model and effort" # Explicit, so the container view shows the write beside the reads; the # implied edge would carry only the first component relation. - binary -> settings "Reads, and writes a file whole for config set" + binary -> settings "Reads, and writes a file whole for config set and for the interview's reviewer list" policy -> settings "Reads" - hostInterface -> settings "Writes a settings file whole, for config set" + hostInterface -> settings "Writes a settings file whole, for config set and for the interview's reviewer list" policy -> catalog "Checks model names" policy -> ports "Records the effective policy and each route" hostInterface -> policy "Settings commands" From f234978f913ae3ba653831c71b46a57017f56889 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 20:52:02 -0400 Subject: [PATCH 25/27] docs(roadmap): show T15 merged and T16 in progress in the roadmap The status sentence, Figure 4 and the Build 3 table follow #253, and a T16 bullet says what the interview provides and what it hands to Build 4 and T17. --- docs/roadmap.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 878ae867..de2cef3a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap -This file is the order of work from now to Baley's first public release: nine builds, then the release design, a live acceptance run on Claude Code, and publishing. Status is Build 2 complete, Build 3 T1 to T14 merged (T14 is [#250](https://github.com/crenshawdev/baley/pull/250)) and T15 in progress on this branch, based on main `beba9141`, dated 2026-10-10, taken from the GitHub issues, milestones and pull requests of crenshawdev/baley and from the [design documents](design/) and [decision records](adr/). No issue, milestone or design document gives a date, so this file shows order only. The plan re-check design [#134](https://github.com/crenshawdev/baley/issues/134) is recorded as landed on 2026-10-06 in [ADR 0037](adr/0037-plan-recheck-scope.md); its implementation belongs to Builds 4 and 5. The integration check design [#229](https://github.com/crenshawdev/baley/issues/229) is recorded as landed on 2026-10-08 in [ADR 0042](adr/0042-mocked-boundary-integration-checks.md); its implementation belongs to Builds 4 and 5. The repository upkeep design [#47](https://github.com/crenshawdev/baley/issues/47) is recorded as landed on 2026-10-06 in [0015](design/0015-repository-upkeep-and-build-gate.md); its remaining implementation belongs to #54, while UPK-R2 is implemented by [Build 3 delivery tasks T14 to T17](#build-3-hosts) ([#24](https://github.com/crenshawdev/baley/issues/24)). The delivery and credential designs are recorded as landed on 2026-10-06 in [ADR 0038](adr/0038-installer-and-opt-in-updates.md) and [ADR 0039](adr/0039-session-owned-provider-credentials.md). Build 3 T16 and T17 are planned, no longer held for a decision. Removal of the built key reader, `baley exec --key` and HTTPS model lister belongs to Build 4 ([#25](https://github.com/crenshawdev/baley/issues/25)). Each build pull request updates this roadmap. +This file is the order of work from now to Baley's first public release: nine builds, then the release design, a live acceptance run on Claude Code, and publishing. Status is Build 2 complete, Build 3 T1 to T15 merged (T15 is [#253](https://github.com/crenshawdev/baley/pull/253)) and T16 in progress on this branch, based on main `191ca9a3`, dated 2026-10-10, taken from the GitHub issues, milestones and pull requests of crenshawdev/baley and from the [design documents](design/) and [decision records](adr/). No issue, milestone or design document gives a date, so this file shows order only. The plan re-check design [#134](https://github.com/crenshawdev/baley/issues/134) is recorded as landed on 2026-10-06 in [ADR 0037](adr/0037-plan-recheck-scope.md); its implementation belongs to Builds 4 and 5. The integration check design [#229](https://github.com/crenshawdev/baley/issues/229) is recorded as landed on 2026-10-08 in [ADR 0042](adr/0042-mocked-boundary-integration-checks.md); its implementation belongs to Builds 4 and 5. The repository upkeep design [#47](https://github.com/crenshawdev/baley/issues/47) is recorded as landed on 2026-10-06 in [0015](design/0015-repository-upkeep-and-build-gate.md); its remaining implementation belongs to #54, while UPK-R2 is implemented by [Build 3 delivery tasks T14 to T17](#build-3-hosts) ([#24](https://github.com/crenshawdev/baley/issues/24)). The delivery and credential designs are recorded as landed on 2026-10-06 in [ADR 0038](adr/0038-installer-and-opt-in-updates.md) and [ADR 0039](adr/0039-session-owned-provider-credentials.md). Build 3 T17 is planned, no longer held for a decision. Removal of the built key reader, `baley exec --key` and HTTPS model lister belongs to Build 4 ([#25](https://github.com/crenshawdev/baley/issues/25)). Each build pull request updates this roadmap. ## The path to the first release @@ -222,12 +222,12 @@ flowchart LR class T1,T2,T3,T4,T5,T6,T7,T8,T9,T10,T11,T12 done class Delivery done class T13 done - class T14 done - class T15 progress - class T16,T17,Release14,Verified planned + class T14,T15 done + class T16 progress + class T17,Release14,Verified planned ``` -Figure 4. Build 3's tasks. Arrows point from a task to what waits on it. The dotted arrow is an evidence link. T14 is merged ([#250](https://github.com/crenshawdev/baley/pull/250)) and T15 is in progress. T16 and T17 are planned, and T14 and T17 use unsigned development artifacts only. #14 fills and qualifies T14's `verify_download` seam before delivery can release verified artifacts; its other build dependencies are listed under R1. One pull request per Build 3 task. +Figure 4. Build 3's tasks. Arrows point from a task to what waits on it. The dotted arrow is an evidence link. T14 and T15 are merged ([#250](https://github.com/crenshawdev/baley/pull/250), [#253](https://github.com/crenshawdev/baley/pull/253)) and T16 is in progress. T17 is planned, and T14 and T17 use unsigned development artifacts only. #14 fills and qualifies T14's `verify_download` seam before delivery can release verified artifacts; its other build dependencies are listed under R1. One pull request per Build 3 task. | Task | What | Pull requests | Status | |---|---|---|---| @@ -245,8 +245,8 @@ Figure 4. Build 3's tasks. Arrows point from a task to what waits on it. The dot | T12 | Live Claude qualification and concurrent sessions. Run on 2026-10-08 against Claude Code 2.1.294 in three runs kept in `spikes/host-matrix`: the first sheet is `claude-live-2026-10-08-run1.md`, the rerun after the fixes for issues #231 and #232 is `claude-live-2026-10-08-run2.md`, and the final run, against commit `7c8ca4c3` after changes to the procedure, is `claude-live-2026-10-08-run3.md`. The two defects the first run showed are `claude-live-2026-10-08-run1.md#exit.session-a` and `claude-live-2026-10-08-run1.md#hand.parts.document-main`, and the rerun passes both (`claude-live-2026-10-08-run2.md#exit.session-a`, `claude-live-2026-10-08-run2.md#hand.parts.document-main`). No barrier failed: the Bash, Read, Write and Edit rows for the home and the config folder pass (`claude-live-2026-10-08-run1.md#bar.home.bash-read`, `claude-live-2026-10-08-run1.md#bar.home.bash-write`, `claude-live-2026-10-08-run1.md#bar.home.read-tool`, `claude-live-2026-10-08-run1.md#bar.home.write-tool`, `claude-live-2026-10-08-run1.md#bar.home.edit-tool`, `claude-live-2026-10-08-run1.md#bar.config.bash-read`, `claude-live-2026-10-08-run1.md#bar.config.bash-write`, `claude-live-2026-10-08-run1.md#bar.config.read-tool`, `claude-live-2026-10-08-run1.md#bar.config.write-tool`, `claude-live-2026-10-08-run1.md#bar.config.edit-tool`). The final run found no defect. It passes Grep and Glob over each folder and over its parent, in a session launched with `--allowedTools Grep,Glob` because Claude Code 2.1.294 leaves both tools out of its default tool list, and the guard refused every call (`claude-live-2026-10-08-run3.md#bar.home.grep-folder`, `claude-live-2026-10-08-run3.md#bar.home.grep-parent`, `claude-live-2026-10-08-run3.md#bar.home.glob-folder`, `claude-live-2026-10-08-run3.md#bar.home.glob-parent`, `claude-live-2026-10-08-run3.md#bar.config.grep-folder`, `claude-live-2026-10-08-run3.md#bar.config.grep-parent`, `claude-live-2026-10-08-run3.md#bar.config.glob-folder`, `claude-live-2026-10-08-run3.md#bar.config.glob-parent`, `claude-live-2026-10-08-run3.md#ctl.grep-parent-guard`, `claude-live-2026-10-08-run3.md#ctl.glob-parent-guard`). It also passes the hook on Grep, Glob, Write, Edit and NotebookEdit (`claude-live-2026-10-08-run3.md#ctl.hook.grep`, `claude-live-2026-10-08-run3.md#ctl.hook.glob`, `claude-live-2026-10-08-run3.md#ctl.hook.write`, `claude-live-2026-10-08-run3.md#ctl.hook.edit`, `claude-live-2026-10-08-run3.md#ctl.hook.notebookedit`), the user-scope registration in an isolated configuration, whose three tools were callable without a tool search in the main session and a subagent (`claude-live-2026-10-08-run3.md#id.user-scope.startup`, `claude-live-2026-10-08-run3.md#hand.tools.user-main`, `claude-live-2026-10-08-run3.md#hand.tools.user-subagent`), and the skill from both placements (`claude-live-2026-10-08-run3.md#hand.skill-listed`, `claude-live-2026-10-08-run3.md#hand.skill-run`, `claude-live-2026-10-08-run3.md#hand.skill-project-listed`, `claude-live-2026-10-08-run3.md#hand.skill-project-run`). Acceptance left open or unverified: keys and detection, because Build 4 (#25) removes the key reader, the credential wrapper and the model lister the row would exercise (`claude-live-2026-10-08-run3.md#hand.keys-detection`); the restore report, which failed on one step, since a repeated `acknowledge-restore` is a new request that exits 1 with "already acknowledged" and prints no purge warning, so whether a replayed acknowledgement carrying the warning is reachable from the command line is open (`claude-live-2026-10-08-run3.md#hand.restore-report`); the PowerShell reads, writes, hook and recorded ask, because `pwsh` was absent in every run (`claude-live-2026-10-08-run3.md#bar.home.ps-read`, `claude-live-2026-10-08-run3.md#ctl.hook.powershell`, `claude-live-2026-10-08-run3.md#ctl.powershell-ask`); the `.git/HEAD` fallback, since with git off `PATH` the guard asked about unreadable settings and never reached the fallback path (`claude-live-2026-10-08-run1.md#ctl.fallback-head`); a hook timeout, since no guard call reached 10,000 ms and the highest took 49 ms (`claude-live-2026-10-08-run3.md#ctl.timeout-not-denial`, `claude-live-2026-10-08-run3.md#ctl.latency`); a live redelivery, since no `tool_use_id` arrived twice (`claude-live-2026-10-08-run3.md#ctl.redelivery`); the burst of calls in flight at exit, since Claude Code held `/exit` until the turn ended and no call was inside the server (`claude-live-2026-10-08-run2.md#exit.burst`); the guard's own deny of a Write to a protected file, since the Edit deny rule refused that Write before the hook ran (`claude-live-2026-10-08-run1.md#ctl.write-baleytoml-denied`); the Edit rule's reach over NotebookEdit, which the read-first check refused first (`claude-live-2026-10-08-run1.md#bar.home.notebook-edit`); host-specific settings, since neither config file had a host section to list (`claude-live-2026-10-08-run1.md#hand.config-show`); and the arrival of served-answer parts after part 1, since only part 1 was read (`claude-live-2026-10-08-run2.md#hand.parts.document-main`) | [#236](https://github.com/crenshawdev/baley/pull/236) | Merged | | T13 | Runtime doctor host observations | [#237](https://github.com/crenshawdev/baley/pull/237) | Merged | | T14 | Binary delivery, ledger update claims and events, version-folder protection and update compatibility using unsigned development artifacts only; leave `verify_download` between download and staging for #14's verifiers | [#250](https://github.com/crenshawdev/baley/pull/250) | Merged; released delivery depends on #14 | -| T15 | Claude artifact ownership and application | | In progress | -| T16 | Owner setup: final summary lists every file, then `yes` records provider acknowledgement, writes API-host allowances through the installation writer, writes global `review.reviewers`, and runs settings `config set` in order. Stop on failure, report completed writes and permit safe rerun | | Planned | +| T15 | Claude artifact ownership and application | [#253](https://github.com/crenshawdev/baley/pull/253) | Merged | +| T16 | Owner setup: final summary lists every file, then `yes` records provider acknowledgement, writes API-host allowances through the installation writer, writes global `review.reviewers`, and runs settings `config set` in order. Stop on failure, report completed writes and permit safe rerun | | In progress | | T17 | Delivery doctor and installed qualification with unsigned development artifacts only, including newer-hook/older-server compatibility | | Planned; released-artifact verification and its qualification belong to #14 | ## Build 4: Planning @@ -255,7 +255,7 @@ Figure 4. Build 3's tasks. Arrows point from a task to what waits on it. The dot The owner starts a project, shapes the story backlog, and commits stories to phases under a task capacity. Each story carries versioned truths, and each truth has one check. The analyzer and the planner ask the owner only the decisions left to the owner, each with a recommended answer, in rounds ordered by what each question depends on, and every answer and deferral is recorded ([ADR 0030](adr/0030-question-rounds.md)). Each phase's plan is written, checked, reviewed and risk-scanned, and approval binds the plan's exact digest. Every configured reviewer runs, and a provider that cannot be called is replaced by the host's own subagent reviewer unless that reviewer already runs. The host session makes the outside calls from Baley's work order and checks each finding, and the owner rules on each one that survives ([ADR 0013](adr/0013-host-session-calls-outside-models.md), [ADR 0019](adr/0019-reviews-adjudicated-and-ruled.md)). The first workers are dispatched here, so routing is finished here. The first dispatch task supplies Claude Code's identity rung map and a dated per-model table of supported effort levels. Claude Code runs an unsupported level as the highest supported level at or below it, and a model without effort is not applicable. The route records the requested rung and the effective level apart. `RungMap` is unchanged, and the owner reviews the initial table in that task's pull request. -Build 4 owns `baley models import`, the provider-list fetch work order and `models-return`, and checks the current provider acknowledgement before issuing review or fetch work. These depend on Build 3 T16's provider choice and typed acknowledgement in the ledger. T16 adds the setup reader and writer of `review.reviewers`; Build 4 adds the review consumer. Build 4 also implements tier selection: an explicit setting, then the owner's entry for the tier, otherwise the newest accepted catalog id tagged for it by `created` date, then hint order, with the catalog record extension in 0003. Build 4 (#25) removes the built `keys.env` reader, `baley exec --key` credential wrapper, HTTPS model lister and credential lookup, and switches `baley init` from detection to seeding. The pure catalog parsing, classification, tagging, diff and event code stays. +Build 4 owns `baley models import`, the provider-list fetch work order and `models-return`, and checks the current provider acknowledgement before issuing review or fetch work. These depend on Build 3 T16's provider choice and typed acknowledgement in the ledger. T16 added the setup reader and writer of `review.reviewers`; Build 4 adds the review consumer. Build 4 also implements tier selection: an explicit setting, then the owner's entry for the tier, otherwise the newest accepted catalog id tagged for it by `created` date, then hint order, with the catalog record extension in 0003. Build 4 (#25) removes the built `keys.env` reader, `baley exec --key` credential wrapper, HTTPS model lister and credential lookup, and switches `baley init` from detection to seeding. The pure catalog parsing, classification, tagging, diff and event code stays. Build 4 implements PLN-R16's full and diff plan re-check inputs, the setting's first reader and validation, immutable material for each round, and the scope recorded in plan-check and plan-review events and views. Design 0005 leaves the way the checker's revision and a plan review's revision combine for one plan as an open question owned by Build 4. @@ -417,4 +417,5 @@ What the records leave open or do not say. - Build 4 (#25) owns removal of the built `keys.env` reader, `baley exec --key` credential wrapper and HTTPS model lister and credential lookup; its task breakdown is not written yet. The removal seams are `crates/baley/src/keys.rs`, `crates/baley/src/exec.rs` and `crates/baley/src/detection/`, with `baley init` switching to seeding. - The release design #14 owns the signed checksum manifest's signature format, trust root and both verifiers, including qualification and any new dependency. Both `install.sh` and the updater now check SHA-256 against an unsigned manifest in `verify_download` before staging. T14 and T17 use unsigned development artifacts only, and nothing is released before #14 supplies and qualifies signature verification. Removing old versions also belongs to #14. - Build 3 T14 now provides `baley update`, the opt-in detached check started by `baley serve`, the development `install.sh`, ledger update claims and events, and staged binaries at `~/.local/lib/crenshawdev/baley/versions//baley`. The placement map projects the versions folder for write-only protection, and the security proposal renders its denials. T14 leaves the view set at 7 and records update events only in `user`, adding no event type to the session's project. T17 still qualifies the live install script, the update path and a newer stable-path hook beside an older running server and its ledger state. T11 supplies the installed executable path to the protected-path projection. -- Build 3 T15 provides `baley install [--host claude-code]`, which writes Claude Code's wiring at the stable path. The Claude folder is `CLAUDE_CONFIG_DIR` when it is set, else `~/.claude`, and one derivation of the stable path and the Claude folder serves install, the live guard and the doctor. Install writes the user settings file `/settings.json` with the pre-tool hook, the `permissions.deny` rules and the sandbox block merged into the owner's settings, the skill stubs under `/skills/`, and the user-scope MCP registration, which it has Claude Code write through `claude mcp add-json` and never writes itself. It replaces an artifact only when this binary's own render or `install.recorded` shows Baley wrote it. Otherwise it refuses `install-ownership-conflict` or `install-settings-conflict` before any write. Then it seeds the catalog and records `install.recorded` in `user`. The `install` view holds those records, so the view set rises from 7 to 8, and a binary built before T15 is read-only on `user` once a newer binary has brought its views to 8. The live guard now protects the placed stubs, the settings file, the registration file and the executable, and the versions folder as write-only. The doctor judges only what the install record shows, so a machine that never ran install still reports every artifact as not installed. An owner setting that weakens protection, a missing `bwrap` or `socat` on Linux, or nothing runnable at the stable path makes the install partial: the receipt names each gap with its fix and the command exits 1. Install writes nothing to `config.toml`. T16 builds on the settings file and the install record: its provider hosts and acknowledgement join them. T17 reads the derived placements and the record in its installed-result checks, and runs the live install and a Claude Code session on the written wiring. Two cases are filed and not closed: a registration changed between install's check and its removal of the old entry ([#251](https://github.com/crenshawdev/baley/issues/251)), and an older recorded hook removed while nothing runnable is at the stable path ([#252](https://github.com/crenshawdev/baley/issues/252)). +- Build 3 T15 provides `baley install [--host claude-code]`, which writes Claude Code's wiring at the stable path. The Claude folder is `CLAUDE_CONFIG_DIR` when it is set, else `~/.claude`, and one derivation of the stable path and the Claude folder serves install, the live guard and the doctor. Install writes the user settings file `/settings.json` with the pre-tool hook, the `permissions.deny` rules and the sandbox block merged into the owner's settings, the skill stubs under `/skills/`, and the user-scope MCP registration, which it has Claude Code write through `claude mcp add-json` and never writes itself. It replaces an artifact only when this binary's own render or `install.recorded` shows Baley wrote it. Otherwise it refuses `install-ownership-conflict` or `install-settings-conflict` before any write. Then it seeds the catalog and records `install.recorded` in `user`. The `install` view holds those records, so the view set rises from 7 to 8, and a binary built before T15 is read-only on `user` once a newer binary has brought its views to 8. The live guard now protects the placed stubs, the settings file, the registration file and the executable, and the versions folder as write-only. The doctor judges only what the install record shows, so a machine that never ran install still reports every artifact as not installed. An owner setting that weakens protection, a missing `bwrap` or `socat` on Linux, or nothing runnable at the stable path makes the install partial: the receipt names each gap with its fix and the command exits 1. Install writes nothing to `config.toml`. T16 builds on the settings file and the install record, as the next bullet says. T17 reads the derived placements and the record in its installed-result checks, and runs the live install and a Claude Code session on the written wiring. Two cases are filed and not closed: a registration changed between install's check and its removal of the old entry ([#251](https://github.com/crenshawdev/baley/issues/251)), and an older recorded hook removed while nothing runnable is at the stable path ([#252](https://github.com/crenshawdev/baley/issues/252)). +- Build 3 T16 provides the owner's setup entry in `baley config interview`. After the thirteen settings questions the interview asks which outside review providers to use, `openai`, `deepseek` or `none`, and a blank answer keeps the stored choice. It shows design 0003's provider warning for each provider named and needs a typed `yes` to each, and it refuses `owner-identity-missing`, before the summary, when git has no `user.name`. The summary lists every file the confirmed writes can change or create. After the final `yes` four writes run in order, and a step with nothing to change is skipped. First, `providers.acknowledged` is recorded in the per-user project `user`, with the providers, the warning version and its SHA-256, git `user.name` and the time; a current acknowledgement is reused, and no view is added, so the view set stays at 8 and a binary built before T16 cannot write `user` once it holds the event. Second, each provider's API host, `api.openai.com` or `api.deepseek.com`, is added to `sandbox.network.allowedDomains` in Claude Code's settings file through the digest-checked replacement, with no install lock, no `install.recorded` and no `claude` command. Third, the global `review.reviewers` is written, and fourth, the settings answers go through the apply function `config set` uses. `review.reviewers` is now in the settings schema, default `["host"]`, with the interview as its setup reader and writer, and `config set` refuses it `list-not-settable`. A failure stops the run, prints its code and the writes already completed, and a rerun finishes only what is missing. `baley install` is unchanged: it records no acknowledgement and keeps every host the interview added. T16 hands Build 4 the recorded acknowledgement and `review.reviewers` for its review and fetch checks, and hands T17 the written settings and the install record, whose settings digest predates the file once the host step has run and is not drift. Enforcing the acknowledgement at issuance stays with Build 4. From 799511ce6df3d2f3fd7b91446301e0e3e9ba2c67 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 20:55:14 -0400 Subject: [PATCH 26/27] docs(readme): stop the README overclaiming key handling and rerun recovery The binary still reads keys and nothing checks the acknowledgement before a review or fetch yet. A rerun does not redo the ledger policy step after a written reviewer list (#255). --- README.md | 4 ++-- docs/design/0003-configuration-and-routing.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9db7b727..5ff3795c 100644 --- a/README.md +++ b/README.md @@ -33,11 +33,11 @@ Automatic updates are opt-in and off by default. Enable them with `baley config ## API keys and outside-review risks -Baley never reads your API keys. Keys live in your environment, where every program Claude Code starts can see them, agents and subagents included. Nothing scrubs a key a command prints. Your code goes to the chosen provider under its terms. +The design requires that Baley never read, store or send your API keys. Keys live in your environment, where every program Claude Code starts can see them, agents and subagents included. Nothing scrubs a key a command prints. Your code goes to the chosen provider under its terms. Release 1 outside reviews use provider APIs only. Baley builds the request and names the address, header and environment variable; the session sends it with your key and returns the raw response for Baley to parse and check. The same boundary applies to model-list refreshes. Provider command-line agents are outside this release. -No outside provider is enabled until you confirm it. `baley config interview` asks which providers to use (`openai`, `deepseek` or `none`) after the settings questions, shows the warning above for each one you pick and needs a typed `yes` to it. Any other answer declines the whole interview and writes nothing. It then shows a summary that names every file it will change. After the final `yes` it records the acknowledgement in the ledger with your git `user.name` (it refuses, before the summary, when git has none), adds the providers' API hosts to Claude Code's settings file, writes `review.reviewers` in `config.toml`, and then writes the settings. If a step fails, it reports the failure and the writes already completed, and running the interview again finishes what is missing. It asks again when the warning changes. A fresh install enables no outside provider; a reinstall keeps acknowledged choices and the hosts the interview added. Install never acknowledges a warning for you. +The interview records your confirmation of an outside provider, but nothing checks that record yet before a review or fetch. Enforcing it when a review or fetch is issued, including for settings edited outside the interview, is Build 4's (#25). `baley config interview` asks which providers to use (`openai`, `deepseek` or `none`) after the settings questions, shows the warning above for each one you pick and needs a typed `yes` to it. Any other answer declines the whole interview and writes nothing. It then shows a summary that names every file it will change. After the final `yes` it records the acknowledgement in the ledger with your git `user.name` (it refuses, before the summary, when git has none), adds the providers' API hosts to Claude Code's settings file, writes `review.reviewers` in `config.toml`, and then writes the settings. If a step fails, it reports the failure and the writes already completed. Running the interview again is safe and redoes any write that did not happen, with one exception: when the reviewer list was written to `config.toml` and the ledger policy step after it failed, a rerun finds the list already equal and does not redo that step ([#255](https://github.com/crenshawdev/baley/issues/255)). It asks again when the warning changes. A fresh install enables no outside provider; a reinstall keeps acknowledged choices and the hosts the interview added. Install never acknowledges a warning for you. The key boundary the warning describes is not built yet. The current binary still contains the `keys.env` reader, `baley exec --key` and the HTTPS model lister. Build 4 (#25) removes those paths. The parked review engine also reads environment keys and `providers.env` and makes provider calls; that code goes with the parked engine in Build 9. See [the credential decision](docs/adr/0039-session-owned-provider-credentials.md) and [configuration build status](docs/design/0003-configuration-and-routing.md#11-build-status). diff --git a/docs/design/0003-configuration-and-routing.md b/docs/design/0003-configuration-and-routing.md index bcf544c0..036bcea1 100644 --- a/docs/design/0003-configuration-and-routing.md +++ b/docs/design/0003-configuration-and-routing.md @@ -299,7 +299,7 @@ Settings and catalog edits are owner commands. Build 4 accepts model-list return 3. **Reviewer list.** It writes the global file's top-level `review.reviewers`, never a `[host.]` section. `config set` refuses this setting (section 5, `baley config set`), so this is a separate write. The new list keeps `host` as the stored top-level list holds it, present or absent, with a file that writes none counting as the default `["host"]`, and then names the providers the owner named, in catalog order. Nothing is written when the stored list, taken as a set, already equals the new one, and a blank provider answer writes nothing (`crates/baley/src/config_command/reviewers.rs:84-104`). The write is bound to the global file's path and to the digest read before the questions. It renders the whole file from the file's own bytes and keeps every other key, refuses a symbolic link, creates the config folder private when it is missing, and returns the SHA-256 of the bytes it wrote (`crates/baley/src/config_command/set.rs:82-161`). In a project that is in this machine's ledger the store is opened before the file is written, so an unsafe ledger refuses with the file as it was, and the policy step runs after the write as it does for `config set`. The refusals are `config-unavailable` and `config-conflict`, exit 2, the ledger codes of step 1, exit 3, and checkout admission's refusals as `config set` reports them. A failure after the file was replaced prints its code and then `wrote , and the change stands`. 4. **Settings.** It runs the apply function shared with `config set` for the non-blank settings answers and its policy step, still bound to the shown target. When step 3 wrote that same global file, the digest it returned is the expected digest for this step, and the apply function reads the file again, so the reviewer list survives. Otherwise the digest read before the questions stays the expected one, or its absence. A change from outside in between still refuses `config-conflict`. The refusals are those of `config set`. - **Failure, report and exit codes:** a failure stops at its step and no later step starts (`crates/baley/src/config_command/interview/report.rs:75-80`). The report, in order, holds the failing step's own lines with its code first, each write completed before it, `Not written:` and the steps never started (`the acknowledgement`, `the API hosts`, `review.reviewers`, `the settings`), and `Run the interview again: it finishes only what is missing.` (`crates/baley/src/config_command/interview/report.rs:153-194`). A refusal exits 2 and a write failure exits 3, as for `config set`. A run without a failure prints a line for each write and exits 0, and when the only step was an acknowledgement a current event already covered, it says `No file was changed.` -- **Running it again:** the interview is safe to rerun with the same answers. It shows the requested changes, and after `yes` the acknowledgement step finds a current event and reuses it, the host step finds every host listed, the reviewer step finds the list equal and `config set` finds every value in place, so no file changes and no acknowledgement is recorded. A reworded warning or a new version covers nothing stored, so the next confirmation records a new event. Baley never infers an acknowledgement from a host allowance or a reviewer setting. +- **Running it again:** the interview is safe to rerun with the same answers. It shows the requested changes, and after `yes` the acknowledgement step finds a current event and reuses it, the host step finds every host listed, the reviewer step finds the list equal and `config set` finds every value in place, so no file changes and no acknowledgement is recorded. A reworded warning or a new version covers nothing stored, so the next confirmation records a new event. One case is not redone: when step 3 wrote the reviewer list and the policy step after the write failed, a rerun finds the list equal and does not run that policy step again (issue #255). Baley never infers an acknowledgement from a host allowance or a reviewer setting. - **Install:** use defaults without questions. `baley install` writes no `network` key and records no acknowledgement, and it keeps every `allowedDomains` host the settings file already holds. A fresh install enables no outside provider, since `review.reviewers` defaults to `["host"]`. A reinstall leaves the ledger's acknowledgements as they are, so acknowledged choices stay. Outside providers stay unavailable without a current acknowledgement, and the interview remains available later. The warning states: Baley never reads your API keys. Keys live in your environment, where every program Claude Code starts can see them, agents and subagents included. Nothing scrubs a key a command prints. Your code goes to the chosen provider under its terms. From 194931155253ea6a0181912df62c7d9048190064 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Sat, 10 Oct 2026 21:15:00 -0400 Subject: [PATCH 27/27] docs(design): say a rerun of the interview is safe only against the same file With no file flag, the interview's default target moves to the project file once the global file holds a role value, so a rerun inside a project writes its settings answers to baley.toml. Design 0003, design 0012 and the README claimed every rerun changes no file. --- README.md | 2 +- docs/design/0003-configuration-and-routing.md | 2 +- docs/design/0012-host-interface.md | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5ff3795c..65db2463 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ The design requires that Baley never read, store or send your API keys. Keys liv Release 1 outside reviews use provider APIs only. Baley builds the request and names the address, header and environment variable; the session sends it with your key and returns the raw response for Baley to parse and check. The same boundary applies to model-list refreshes. Provider command-line agents are outside this release. -The interview records your confirmation of an outside provider, but nothing checks that record yet before a review or fetch. Enforcing it when a review or fetch is issued, including for settings edited outside the interview, is Build 4's (#25). `baley config interview` asks which providers to use (`openai`, `deepseek` or `none`) after the settings questions, shows the warning above for each one you pick and needs a typed `yes` to it. Any other answer declines the whole interview and writes nothing. It then shows a summary that names every file it will change. After the final `yes` it records the acknowledgement in the ledger with your git `user.name` (it refuses, before the summary, when git has none), adds the providers' API hosts to Claude Code's settings file, writes `review.reviewers` in `config.toml`, and then writes the settings. If a step fails, it reports the failure and the writes already completed. Running the interview again is safe and redoes any write that did not happen, with one exception: when the reviewer list was written to `config.toml` and the ledger policy step after it failed, a rerun finds the list already equal and does not redo that step ([#255](https://github.com/crenshawdev/baley/issues/255)). It asks again when the warning changes. A fresh install enables no outside provider; a reinstall keeps acknowledged choices and the hosts the interview added. Install never acknowledges a warning for you. +The interview records your confirmation of an outside provider, but nothing checks that record yet before a review or fetch. Enforcing it when a review or fetch is issued, including for settings edited outside the interview, is Build 4's (#25). `baley config interview` asks which providers to use (`openai`, `deepseek` or `none`) after the settings questions, shows the warning above for each one you pick and needs a typed `yes` to it. Any other answer declines the whole interview and writes nothing. It then shows a summary that names every file it will change. After the final `yes` it records the acknowledgement in the ledger with your git `user.name` (it refuses, before the summary, when git has none), adds the providers' API hosts to Claude Code's settings file, writes `review.reviewers` in `config.toml`, and then writes the settings. If a step fails, it reports the failure and the writes already completed. Running the interview again against the same file is safe and redoes any write that did not happen. Without `--global` or `--project`, a rerun inside a project writes your settings answers to `baley.toml` once `config.toml` holds a role value, so pass `--global` to rerun against `config.toml`. There is one exception: when the reviewer list was written to `config.toml` and the ledger policy step after it failed, a rerun finds the list already equal and does not redo that step ([#255](https://github.com/crenshawdev/baley/issues/255)). It asks again when the warning changes. A fresh install enables no outside provider; a reinstall keeps acknowledged choices and the hosts the interview added. Install never acknowledges a warning for you. The key boundary the warning describes is not built yet. The current binary still contains the `keys.env` reader, `baley exec --key` and the HTTPS model lister. Build 4 (#25) removes those paths. The parked review engine also reads environment keys and `providers.env` and makes provider calls; that code goes with the parked engine in Build 9. See [the credential decision](docs/adr/0039-session-owned-provider-credentials.md) and [configuration build status](docs/design/0003-configuration-and-routing.md#11-build-status). diff --git a/docs/design/0003-configuration-and-routing.md b/docs/design/0003-configuration-and-routing.md index 036bcea1..2bef3ed3 100644 --- a/docs/design/0003-configuration-and-routing.md +++ b/docs/design/0003-configuration-and-routing.md @@ -299,7 +299,7 @@ Settings and catalog edits are owner commands. Build 4 accepts model-list return 3. **Reviewer list.** It writes the global file's top-level `review.reviewers`, never a `[host.]` section. `config set` refuses this setting (section 5, `baley config set`), so this is a separate write. The new list keeps `host` as the stored top-level list holds it, present or absent, with a file that writes none counting as the default `["host"]`, and then names the providers the owner named, in catalog order. Nothing is written when the stored list, taken as a set, already equals the new one, and a blank provider answer writes nothing (`crates/baley/src/config_command/reviewers.rs:84-104`). The write is bound to the global file's path and to the digest read before the questions. It renders the whole file from the file's own bytes and keeps every other key, refuses a symbolic link, creates the config folder private when it is missing, and returns the SHA-256 of the bytes it wrote (`crates/baley/src/config_command/set.rs:82-161`). In a project that is in this machine's ledger the store is opened before the file is written, so an unsafe ledger refuses with the file as it was, and the policy step runs after the write as it does for `config set`. The refusals are `config-unavailable` and `config-conflict`, exit 2, the ledger codes of step 1, exit 3, and checkout admission's refusals as `config set` reports them. A failure after the file was replaced prints its code and then `wrote , and the change stands`. 4. **Settings.** It runs the apply function shared with `config set` for the non-blank settings answers and its policy step, still bound to the shown target. When step 3 wrote that same global file, the digest it returned is the expected digest for this step, and the apply function reads the file again, so the reviewer list survives. Otherwise the digest read before the questions stays the expected one, or its absence. A change from outside in between still refuses `config-conflict`. The refusals are those of `config set`. - **Failure, report and exit codes:** a failure stops at its step and no later step starts (`crates/baley/src/config_command/interview/report.rs:75-80`). The report, in order, holds the failing step's own lines with its code first, each write completed before it, `Not written:` and the steps never started (`the acknowledgement`, `the API hosts`, `review.reviewers`, `the settings`), and `Run the interview again: it finishes only what is missing.` (`crates/baley/src/config_command/interview/report.rs:153-194`). A refusal exits 2 and a write failure exits 3, as for `config set`. A run without a failure prints a line for each write and exits 0, and when the only step was an acknowledgement a current event already covered, it says `No file was changed.` -- **Running it again:** the interview is safe to rerun with the same answers. It shows the requested changes, and after `yes` the acknowledgement step finds a current event and reuses it, the host step finds every host listed, the reviewer step finds the list equal and `config set` finds every value in place, so no file changes and no acknowledgement is recorded. A reworded warning or a new version covers nothing stored, so the next confirmation records a new event. One case is not redone: when step 3 wrote the reviewer list and the policy step after the write failed, a rerun finds the list equal and does not run that policy step again (issue #255). Baley never infers an acknowledgement from a host allowance or a reviewer setting. +- **Running it again:** the interview is safe to rerun with the same answers when the rerun writes the same settings file. It shows the requested changes, and after `yes` the acknowledgement step finds a current event and reuses it, the host step finds every host listed, the reviewer step finds the list equal and `config set` finds every value in place, so no file changes and no acknowledgement is recorded. A rerun with neither flag can write a different file: once a run has written a role value to the global file, the default inside a project is the project file (the target rules above), and a settings answer is sent even when it equals the value in force, so the rerun writes it to `baley.toml` (`crates/baley/src/config_command/interview.rs:339-362`). Outside a project the same rerun is refused `not-a-project`. Passing `--global` reruns against the global file. The provider steps do not depend on the settings target. A reworded warning or a new version covers nothing stored, so the next confirmation records a new event. One case is not redone: when step 3 wrote the reviewer list and the policy step after the write failed, a rerun finds the list equal and does not run that policy step again (issue #255). Baley never infers an acknowledgement from a host allowance or a reviewer setting. - **Install:** use defaults without questions. `baley install` writes no `network` key and records no acknowledgement, and it keeps every `allowedDomains` host the settings file already holds. A fresh install enables no outside provider, since `review.reviewers` defaults to `["host"]`. A reinstall leaves the ledger's acknowledgements as they are, so acknowledged choices stay. Outside providers stay unavailable without a current acknowledgement, and the interview remains available later. The warning states: Baley never reads your API keys. Keys live in your environment, where every program Claude Code starts can see them, agents and subagents included. Nothing scrubs a key a command prints. Your code goes to the chosen provider under its terms. diff --git a/docs/design/0012-host-interface.md b/docs/design/0012-host-interface.md index 8ee027db..9c65183c 100644 --- a/docs/design/0012-host-interface.md +++ b/docs/design/0012-host-interface.md @@ -274,7 +274,7 @@ Sandbox filesystem paths use ordinary absolute paths; `Read` and `Edit` rules us The library renders this shape, without the `network` block, from the resolved folders, the executable, the write-only files and the write-only folders, and reports a supplied path that is not absolute UTF-8, that a rule would read as a pattern, or that ends in a space a deny rule would drop, instead of rendering it (`crates/baley/src/host_artifacts/security.rs:92-110, 144-223`). It composes the shape into an existing settings document, keeping every unrelated key, `sandbox.network` included. Where the shape is composed, Baley's secure values replace a disabled sandbox, `failIfUnavailable` false and `allowUnsandboxedCommands` true, with each replacement reported. Composition reports and retains `sandbox.filesystem.disabled` true, `disableAllHooks` true, and `sandbox.enabled` false, `failIfUnavailable` false or `allowUnsandboxedCommands` true in a document the shape is not composed into, such as one holding only the hook or the registration. It also reports and retains every `sandbox.excludedCommands` entry, whatever it names, since Claude Code runs a listed command outside the sandbox even with `allowUnsandboxedCommands` false; the home or config folder re-opened by an absolute `allowRead` or `allowWrite` entry; a `~/` or relative `allowRead` or `allowWrite` entry, which Claude Code resolves against the owner's home folder or the settings file's place and which composition therefore cannot judge; a differing hook running `guard`; and a `mcpServers.baley` entry with another command or arguments, with `env` entries, or of a transport other than `stdio`. A conflict marks the result incomplete. `baley install` sorts what composition reports into refusals, replacements and gaps (section 5) (`crates/baley/src/host_artifacts/compose.rs:98-175, 206-287`, `crates/baley/src/install/settings.rs:352-489`). The coverage judge reports what the resulting document covers per tool and per read or write (`crates/baley/src/host_artifacts/coverage.rs:281-356`). Any excluded command leaves Bash, Monitor and PowerShell and every write-only file a gap (`crates/baley/src/host_artifacts/coverage.rs:375-398`), and a `~/` or relative allow entry leaves each verdict its list can open a gap, reported as not judged (`crates/baley/src/host_artifacts/coverage.rs:429-442`). Both read only these settings, in the one document they are given: a setting Claude Code also honours, such as `sandbox.enabledPlatforms` leaving out this platform, or one in another settings file, is not seen. The placement projection carries the installed executable path and the separate versions folder (`crates/baley/src/host_artifacts/placement.rs:290-330`). The coverage judge currently takes only home, config and write-only files, so the installed versions folder needs T17's checks. Install applies the composed result to the settings file, and the guard protects the same placements. The `network` block is the interview's, written for the chosen providers through the settings part of the installation writer; neither the library nor install renders a network key. -`baley config interview` takes provider choice and typed acknowledgement after its thirteen settings questions ([0003](0003-configuration-and-routing.md), CFG-R28, section 5). The owner answers `openai`, `deepseek`, both separated by a comma, or `none`, and a blank answer keeps the stored choice (`crates/baley/src/config_command/interview/providers.rs:73-107, 116-180`). For each provider the owner names, the interview shows the warning and takes a typed `yes`. The warning states: Baley never reads your API keys. Keys live in your environment, where every program Claude Code starts can see them, agents and subagents included. Nothing scrubs a key a command prints. Your code goes to the chosen provider under its terms. It then reads git `user.name` as the owner and refuses `owner-identity-missing` before the summary when git has none. The summary before the final `yes` lists every file the writes can change or create (`crates/baley/src/config_command/interview/summary.rs:58-166`). That `yes` records the acknowledgement first, or reuses a current one, adds the chosen providers' API hosts second, writes the global `review.reviewers` list third, and runs the settings `config set` last (`crates/baley/src/config_command/interview.rs:140-185`). The host step is the settings-file part of the installation writer: it reads the user settings file as install reads it, adds each chosen provider's host to `sandbox.network.allowedDomains`, keeps every other key and host, and writes through the digest-checked replacement, so a file changed since it was read is refused as `install-settings-conflict` (`crates/baley/src/install/hosts.rs:91-190`). It takes no install lock, records no `install.recorded`, starts no `claude` command and writes no stub. Hosts are only added, so choosing `none` later leaves them in place. These are separate writes. A failure stops at that step and reports its code and what was written, and running the interview again reads the completed state and applies only the remaining changes. The acknowledgement is recorded in the ledger and asked again when the warning changes. A fresh install enables no outside provider, and a reinstall keeps acknowledged choices. Install never acknowledges a warning. +`baley config interview` takes provider choice and typed acknowledgement after its thirteen settings questions ([0003](0003-configuration-and-routing.md), CFG-R28, section 5). The owner answers `openai`, `deepseek`, both separated by a comma, or `none`, and a blank answer keeps the stored choice (`crates/baley/src/config_command/interview/providers.rs:73-107, 116-180`). For each provider the owner names, the interview shows the warning and takes a typed `yes`. The warning states: Baley never reads your API keys. Keys live in your environment, where every program Claude Code starts can see them, agents and subagents included. Nothing scrubs a key a command prints. Your code goes to the chosen provider under its terms. It then reads git `user.name` as the owner and refuses `owner-identity-missing` before the summary when git has none. The summary before the final `yes` lists every file the writes can change or create (`crates/baley/src/config_command/interview/summary.rs:58-166`). That `yes` records the acknowledgement first, or reuses a current one, adds the chosen providers' API hosts second, writes the global `review.reviewers` list third, and runs the settings `config set` last (`crates/baley/src/config_command/interview.rs:140-185`). The host step is the settings-file part of the installation writer: it reads the user settings file as install reads it, adds each chosen provider's host to `sandbox.network.allowedDomains`, keeps every other key and host, and writes through the digest-checked replacement, so a file changed since it was read is refused as `install-settings-conflict` (`crates/baley/src/install/hosts.rs:91-190`). It takes no install lock, records no `install.recorded`, starts no `claude` command and writes no stub. Hosts are only added, so choosing `none` later leaves them in place. These are separate writes. A failure stops at that step and reports its code and what was written, and running the interview again against the same settings file reads the completed state and applies only the remaining changes. A rerun with neither flag inside a project writes the settings answers to the project file once the global file holds a role value, as [0003](0003-configuration-and-routing.md) section 5 says. The acknowledgement is recorded in the ledger and asked again when the warning changes. A fresh install enables no outside provider, and a reinstall keeps acknowledged choices. Install never acknowledges a warning. ### baley update @@ -599,7 +599,7 @@ sequenceDiagram B->>HC: add the chosen API hosts through the installation writer, no install lock and no install.recorded B->>C: write global review.reviewers and run its policy step B->>C: settings config set and its policy step - Note over B,C: Stop at the first failure and report the completed writes, rerunning is safe + Note over B,C: Stop at the first failure and report the completed writes, rerunning against the same file is safe ``` *Figure 7. The one-command installation, then the interview's provider setup, both as built. The script runs `baley install` through the stable path. Install takes its lock, reads the install record and the placed files, then writes the settings file and the stubs, asks `claude` to register the server, seeds the catalog and records the result. A refusal ends it before any write, and a write that fails stops the run and leaves a partial receipt. Default setup never supplies acknowledgement. The interview, run later, shows the warning for each provider the owner names and takes a typed `yes` to each. Its final confirmation authorizes four separate writes in order, a failure stops that sequence, and the host step takes no install lock and records no `install.recorded`. The released installer verifies every download through #14's verifier; T14 and T17 use unsigned development artifacts only before #14.*