diff --git a/crates/brokerd/src/args.rs b/crates/brokerd/src/args.rs index e052297..aa01ce0 100644 --- a/crates/brokerd/src/args.rs +++ b/crates/brokerd/src/args.rs @@ -4,6 +4,7 @@ //! path, a host or a URL is well formed. The module is pure: no I/O, no clock. Everything it reads //! was written by the model, so it is treated as hostile. +use bxcore::AskMemoryArgs; use bxcore::tools::{HttpFetchArgs, ReadFileArgs, ShellArgs, WriteFileArgs}; use bxcore::{MemoryArgs, RecallArgs}; use std::fmt; @@ -15,8 +16,8 @@ pub const MAX_URL: usize = 2048; /// Maximum length, in characters, of the detail an invalid call is denied with. pub const MAX_DETAIL: usize = 300; -/// The tools the broker can dispatch to: four that run in a container, and the two memory -/// tools that `memoryd` runs (M5a spec, section 8). +/// The tools the broker can dispatch to: four that run in a container, and the three memory +/// tools that `memoryd` runs (M5a spec, section 8; M5c spec, section 9). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ToolName { ReadFile, @@ -25,6 +26,7 @@ pub enum ToolName { HttpFetch, Recall, Memory, + AskMemory, } impl ToolName { @@ -45,6 +47,7 @@ impl ToolName { "http_fetch" => Some(ToolName::HttpFetch), "recall" => Some(ToolName::Recall), "memory" => Some(ToolName::Memory), + "ask_memory" => Some(ToolName::AskMemory), _ => None, } } @@ -58,12 +61,16 @@ impl ToolName { ToolName::HttpFetch => "http_fetch", ToolName::Recall => "recall", ToolName::Memory => "memory", + ToolName::AskMemory => "ask_memory", } } /// Whether `memoryd` runs this tool rather than a container. pub fn is_memory(self) -> bool { - matches!(self, ToolName::Recall | ToolName::Memory) + matches!( + self, + ToolName::Recall | ToolName::Memory | ToolName::AskMemory + ) } } @@ -84,6 +91,7 @@ pub enum ToolArgs { HttpFetch(FetchUrl), Recall(RecallArgs), Memory(MemoryArgs), + AskMemory(AskMemoryArgs), } /// A URL that passed the checks, and its host. Only `parse` makes one, so the host policy matched @@ -128,6 +136,7 @@ impl ToolArgs { ToolArgs::HttpFetch(_) => ToolName::HttpFetch, ToolArgs::Recall(_) => ToolName::Recall, ToolArgs::Memory(_) => ToolName::Memory, + ToolArgs::AskMemory(_) => ToolName::AskMemory, } } @@ -167,6 +176,9 @@ impl ToolArgs { ToolArgs::Memory(value) => { serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string()) } + ToolArgs::AskMemory(value) => { + serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string()) + } } } } @@ -284,6 +296,12 @@ pub fn parse(tool: ToolName, arguments: &str) -> Result { value.check().map_err(ArgsError::Shape)?; Ok(ToolArgs::Memory(value)) } + ToolName::AskMemory => { + let value: AskMemoryArgs = serde_json::from_str(arguments) + .map_err(|error| ArgsError::Shape(error.to_string()))?; + value.check().map_err(ArgsError::Shape)?; + Ok(ToolArgs::AskMemory(value)) + } } } diff --git a/crates/brokerd/src/builtin.rs b/crates/brokerd/src/builtin.rs index 86ef653..5c05ee5 100644 --- a/crates/brokerd/src/builtin.rs +++ b/crates/brokerd/src/builtin.rs @@ -1,6 +1,7 @@ -//! The built-in grants of `recall` and `memory` (M5a spec, section 8). Reading and writing memory -//! is what an agent is, so no grant file can turn it off or put questions in front of it; the -//! prompt is protected by `memoryd`, which lets only clean sessions edit identity. +//! The built-in grants of `recall`, `memory` and `ask_memory` (M5a spec, section 8; M5c spec, +//! section 9). Reading and writing memory is what an agent is, so no grant file can turn it off or +//! put questions in front of it; the prompt is protected by `memoryd`, which lets only clean +//! sessions edit identity. use bxcore::{Grant, GrantProblem, sha256}; @@ -12,17 +13,23 @@ pub const RESERVED: &str = "builtin-"; /// The built-in grants' text, hashed for the audit log like a grant file's bytes. pub const RECALL: &str = "tool = \"recall\"\nmode = \"auto\"\nmax_taint = \"secret\"\nresult_class = \"private\"\nuntrusted = false\n"; pub const MEMORY: &str = "tool = \"memory\"\nmode = \"auto\"\nmax_taint = \"secret\"\nresult_class = \"private\"\nuntrusted = false\n"; +pub const ASK_MEMORY: &str = "tool = \"ask_memory\"\nmode = \"auto\"\nmax_taint = \"secret\"\nresult_class = \"private\"\nuntrusted = false\n"; /// The sentence for an owner's grant naming a memory tool. -pub const BUILT_IN: &str = "the policy of recall and memory is built in; remove this grant"; +pub const BUILT_IN: &str = + "the policy of recall, memory and ask_memory is built in; remove this grant"; -/// The two built-in grants, `builtin-memory` and `builtin-recall`. +/// The three built-in grants, `builtin-memory`, `builtin-recall` and `builtin-ask-memory`. pub fn grants() -> Result, GrantProblem> { - // For each (id, text) of [("builtin-memory", MEMORY), ("builtin-recall", RECALL)]: + // For each (id, text) of `sources`: // `toml::from_str::(text)` and `sha256(text.as_bytes())`, each error as a GrantProblem { // file: id, line: None, problem: e.to_string() }; build LoadedGrant { id, grant, sha256 }. // Collect into Result, GrantProblem> (the first error wins). - let sources: &[(&str, &str)] = &[("builtin-memory", MEMORY), ("builtin-recall", RECALL)]; + let sources: &[(&str, &str)] = &[ + ("builtin-memory", MEMORY), + ("builtin-recall", RECALL), + ("builtin-ask-memory", ASK_MEMORY), + ]; let mut out: Vec = Vec::new(); for (id, text) in sources { let problem = |error: String| GrantProblem { diff --git a/crates/brokerd/src/config.rs b/crates/brokerd/src/config.rs index 1c6d8b5..dfecd7b 100644 --- a/crates/brokerd/src/config.rs +++ b/crates/brokerd/src/config.rs @@ -124,7 +124,9 @@ impl Runner { crate::args::ToolName::Shell => self.shell_ms, crate::args::ToolName::HttpFetch => self.http_fetch_ms, // `memoryd` runs these; they never reach a container. - crate::args::ToolName::Recall | crate::args::ToolName::Memory => self.read_file_ms, + crate::args::ToolName::Recall + | crate::args::ToolName::Memory + | crate::args::ToolName::AskMemory => self.read_file_ms, }; std::time::Duration::from_millis(ms) } diff --git a/crates/brokerd/src/policy/matching.rs b/crates/brokerd/src/policy/matching.rs index 52f7e5f..6876ce1 100644 --- a/crates/brokerd/src/policy/matching.rs +++ b/crates/brokerd/src/policy/matching.rs @@ -94,7 +94,7 @@ fn covers(args: &ToolArgs, grant: &Grant) -> Option> { } ToolArgs::Shell { cwd: Some(cwd), .. } => best_path(grant, cwd, true), // The memory tools take no constraints: their grants are built in (M5a spec, section 8). - ToolArgs::Recall(_) | ToolArgs::Memory(_) => Some(None), + ToolArgs::Recall(_) | ToolArgs::Memory(_) | ToolArgs::AskMemory(_) => Some(None), ToolArgs::HttpFetch(target) => { if grant .constraints diff --git a/crates/brokerd/src/runner.rs b/crates/brokerd/src/runner.rs index 7810f34..9b2730e 100644 --- a/crates/brokerd/src/runner.rs +++ b/crates/brokerd/src/runner.rs @@ -129,7 +129,7 @@ pub fn run(decision: Decision, runtime: &dyn Runtime) -> bxcore::ToolResponse { None, ), ToolArgs::HttpFetch(_) => (Vec::new(), Some(decision.hosts().to_vec())), - ToolArgs::Recall(_) | ToolArgs::Memory(_) => (Vec::new(), None), + ToolArgs::Recall(_) | ToolArgs::Memory(_) | ToolArgs::AskMemory(_) => (Vec::new(), None), }; let spec = RunSpec { session: decision.request().session.clone(), diff --git a/crates/brokerd/tests/ask_memory_tool.rs b/crates/brokerd/tests/ask_memory_tool.rs new file mode 100644 index 0000000..a719cb2 --- /dev/null +++ b/crates/brokerd/tests/ask_memory_tool.rs @@ -0,0 +1,340 @@ +//! `ask_memory` in `brokerd`: its name and arguments, the built-in grant that allows it in every +//! session, grant files that name it refused, its calls routed to `memoryd` and never to a +//! container, and the labels of `memoryd`'s answer raising the session (M5c spec, section 9). +//! Do not edit. + +#[path = "support/build.rs"] +mod build; +#[path = "support/client.rs"] +mod client; +#[path = "support/fake_podman.rs"] +mod fake_podman; +#[path = "support/rig.rs"] +mod rig; +#[path = "support/runtime.rs"] +mod runtime; +#[path = "support/sink.rs"] +mod sink; +#[path = "support/tmp.rs"] +mod tmp; + +use std::os::unix::net::UnixListener; +use std::path::Path; +use std::sync::{Arc, mpsc}; +use std::time::Duration; + +use brokerd::args::{ArgsError, ToolArgs, ToolName, parse}; +use brokerd::broker::Broker; +use brokerd::builtin::{ASK_MEMORY, BUILT_IN}; +use brokerd::container::{IN_MEMORYD, Podman}; +use brokerd::grants::load; +use brokerd::memory_port::{MemoryPort, Routed}; +use brokerd::policy::{Label, SessionState, decide}; +use brokerd::runner::run; +use build::{allowed, now, private, secret, set}; +use bxcore::{ + AskMemoryArgs, DataClass, Envelope, MemoryCall, Message, SessionId, ToolResponse, read_frame, + sha256, write_frame, +}; +use fake_podman::{Fake, Lines, serial}; +use rig::Rig; +use runtime::{Recording, Shared}; +use tmp::TempDir; + +/// A fake `memoryd`: answers one call with `answer` and hands back what it was sent. A failed +/// write is ignored: the test has its answer or has already failed. +fn fake_memoryd(socket: &Path, answer: ToolResponse) -> mpsc::Receiver { + let listener = UnixListener::bind(socket).unwrap(); + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let Ok((mut stream, _)) = listener.accept() else { + return; + }; + let Ok(env) = read_frame(&mut stream) else { + return; + }; + let Message::MemoryCall(call) = env.msg else { + return; + }; + let _ = tx.send(call); + let reply = Envelope { + v: 1, + id: env.id, + r#final: true, + msg: Message::ToolResponse(answer), + }; + let _ = write_frame(&mut stream, &reply); + }); + rx +} + +fn answer(class: DataClass, untrusted: bool) -> ToolResponse { + ToolResponse::Result { + content: "own: memory · The owner prefers UTC.".to_string(), + class, + untrusted, + truncated: false, + } +} + +fn routed(socket: &Path, other: &Arc) -> Routed { + Routed { + memory: MemoryPort::new(socket.to_path_buf(), Duration::from_secs(5)), + other: Box::new(Shared(Arc::clone(other))), + } +} + +const QUESTION: &str = r#"{"question":"What does the owner prefer?"}"#; + +#[test] +fn the_name_and_the_arguments() { + assert_eq!(ToolName::parse("ask_memory"), Some(ToolName::AskMemory)); + assert_eq!(ToolName::AskMemory.as_str(), "ask_memory"); + assert!(ToolName::AskMemory.is_memory()); + assert!( + !ToolName::ALL.contains(&ToolName::AskMemory), + "ALL is the container tools" + ); + let args = parse( + ToolName::AskMemory, + r#"{ "workspaces": ["fleet", "boxmaker1"], "question": "Who wrote it?" }"#, + ) + .unwrap(); + assert_eq!(args.tool(), ToolName::AskMemory); + assert_eq!( + args, + ToolArgs::AskMemory(AskMemoryArgs { + question: "Who wrote it?".to_string(), + workspaces: vec!["fleet".to_string(), "boxmaker1".to_string()], + }) + ); + assert_eq!( + args.canonical_json(), + r#"{"question":"Who wrote it?","workspaces":["fleet","boxmaker1"]}"# + ); + let default = parse(ToolName::AskMemory, QUESTION).unwrap(); + assert_eq!( + default.canonical_json(), + r#"{"question":"What does the owner prefer?","workspaces":[]}"#, + "every field is written, as recall's k is" + ); +} + +/// The refusals are `bxcore`'s sentences, shown to the model after `invalid argument shape: `. +#[test] +fn bad_arguments_are_refused_with_the_checks_sentences() { + let long = format!(r#"{{"question":"{}"}}"#, "é".repeat(2_001)); + for (json, sentence) in [ + ( + r#"{"question":" "}"#, + "question is empty; write the question to ask", + ), + ( + long.as_str(), + "question has 2001 characters, over the limit of 2000; ask a shorter question", + ), + ( + r#"{"question":"q","workspaces":["own","untrusted","fleet"]}"#, + "workspaces names 3, over the limit of 2; ask about the others in another call", + ), + ( + r#"{"question":"q","workspaces":["fleet","fleet"]}"#, + "workspaces names fleet twice; name each workspace once", + ), + ( + r#"{"question":"q","workspaces":[""]}"#, + "a workspace name is empty; name a workspace, or leave workspaces out to ask own", + ), + ( + r#"{"question":"q","workspaces":["a.b"]}"#, + "a workspace name has 1 to 512 letters, digits, _ or -; use own, untrusted, fleet or \ + another agent's name", + ), + ] { + let error = parse(ToolName::AskMemory, json).unwrap_err(); + assert_eq!(error, ArgsError::Shape(sentence.to_string()), "{json}"); + assert_eq!( + error.detail(), + format!("invalid argument shape: {sentence}") + ); + } + for json in [ + r#"{"question":"q","reasoning":"low"}"#, + r#"{"workspaces":["own"]}"#, + "not json", + ] { + let error = parse(ToolName::AskMemory, json).unwrap_err(); + assert!(matches!(error, ArgsError::Shape(_)), "{json}: {error:?}"); + } +} + +/// The built-in grant allows `ask_memory` in every session, `secret` and untrusted ones too: a +/// question is not written to Honcho (M5c spec, section 8). Its results are labelled `private` +/// and trusted; `memoryd`'s own labels can only make them stricter. +#[test] +fn the_built_in_grant_allows_it_in_every_session() { + assert_eq!( + ASK_MEMORY, + "tool = \"ask_memory\"\nmode = \"auto\"\nmax_taint = \"secret\"\nresult_class = \ + \"private\"\nuntrusted = false\n" + ); + let grants = set(vec![]); + let untrusted = SessionState { + taint: DataClass::Secret, + untrusted: true, + }; + for state in [private(), secret(), untrusted] { + let d = allowed(decide( + build::request("ask_memory", QUESTION), + &grants, + state, + now(), + )); + assert_eq!(d.grant(), "builtin-ask-memory"); + assert_eq!(d.grant_sha256(), sha256(ASK_MEMORY.as_bytes()).unwrap()); + assert_eq!( + d.label(), + Label { + class: DataClass::Private, + untrusted: false + } + ); + } + let ids: Vec<&str> = grants.matchable().map(|g| g.id.as_str()).collect(); + assert_eq!( + ids, + ["builtin-memory", "builtin-recall", "builtin-ask-memory"] + ); +} + +#[test] +fn a_grant_file_cannot_name_it() { + assert_eq!( + BUILT_IN, + "the policy of recall, memory and ask_memory is built in; remove this grant" + ); + let dir = TempDir::new("ask-grants"); + std::fs::write( + dir.path().join("mine.toml"), + "tool = \"ask_memory\"\nmode = \"deny\"\nmax_taint = \"secret\"\n", + ) + .unwrap(); + let problems = load(dir.path()).unwrap_err(); + let got: Vec<(String, String)> = problems.into_iter().map(|p| (p.file, p.problem)).collect(); + assert_eq!(got, [("mine.toml".to_string(), BUILT_IN.to_string())]); +} + +#[test] +fn it_goes_to_memoryd_with_its_canonical_arguments() { + let dir = TempDir::new("ask-route"); + let socket = dir.path().join("memory.sock"); + let seen = fake_memoryd(&socket, answer(DataClass::Private, false)); + let other = Recording::answering("container"); + let asked = r#"{"workspaces":["own","fleet"],"question":"Who wrote it?"}"#; + let got = run( + allowed(decide( + build::request("ask_memory", asked), + &set(vec![]), + secret(), + now(), + )), + &routed(&socket, &other), + ); + assert_eq!(got, answer(DataClass::Private, false)); + let call = seen.recv_timeout(Duration::from_secs(5)).unwrap(); + assert_eq!(call.request.tool, "ask_memory"); + assert_eq!( + call.request.arguments, + r#"{"question":"Who wrote it?","workspaces":["own","fleet"]}"# + ); + assert_eq!((call.taint, call.untrusted), (DataClass::Secret, false)); + assert_eq!(other.count(), 0, "nothing reached the other runtime"); +} + +#[test] +fn a_container_runtime_never_runs_it() { + let _s = serial(); + let fake = Fake::new("ask", "exit 0"); + let log = Lines::default(); + let podman = Podman::new(fake.runner(""), fake.dir.join("egress"), log.sink()); + let got = run( + allowed(decide( + build::request("ask_memory", QUESTION), + &set(vec![]), + private(), + now(), + )), + &podman, + ); + assert_eq!( + got, + ToolResponse::Failed { + message: IN_MEMORYD.to_string() + } + ); + assert!(fake.calls().is_empty(), "podman was never called"); +} + +/// One `ask_memory` call through `broker::handle`, whose runtime sends it to a fake `memoryd` +/// answering `reply`; the session's state afterwards. +fn state_after(tag: &str, reply: ToolResponse) -> SessionState { + let rig = Rig::new(tag); + let socket = rig.dir.path().join("memory.sock"); + let _seen = fake_memoryd(&socket, reply); + let other = Recording::answering("unused"); + let broker = Arc::new(Broker::new( + rig.cfg.clone(), + rig.ledger(), + Box::new(routed(&socket, &other)), + rig.lines.sink(), + )); + let frames = client::call(&broker, rig::request("s1", 1, "ask_memory", QUESTION)); + assert!( + matches!( + frames.as_slice(), + [Envelope { + msg: Message::ToolResponse(ToolResponse::Result { .. }), + .. + }] + ), + "{frames:?}" + ); + rig.state().read(&SessionId::new("s1").unwrap()).unwrap() +} + +/// T58: an answer from a workspace that is not trusted makes a clean session untrusted, as a +/// `memory-tainted` recall hit does. +#[test] +fn an_untrusted_answer_makes_the_session_untrusted() { + assert_eq!( + state_after("ask-untrusted", answer(DataClass::Private, true)), + SessionState { + taint: DataClass::Private, + untrusted: true + } + ); +} + +/// T58: an answer labelled `secret` raises the session to `secret`. +#[test] +fn a_secret_answer_raises_the_session_to_secret() { + assert_eq!( + state_after("ask-secret", answer(DataClass::Secret, false)), + SessionState { + taint: DataClass::Secret, + untrusted: false + } + ); +} + +/// The grant's label is the floor: a `public` answer still leaves the session `private`. +#[test] +fn a_public_answer_leaves_the_session_private() { + assert_eq!( + state_after("ask-public", answer(DataClass::Public, false)), + SessionState { + taint: DataClass::Private, + untrusted: false + } + ); +} diff --git a/crates/brokerd/tests/grants.rs b/crates/brokerd/tests/grants.rs index 284f283..bd1dfe2 100644 --- a/crates/brokerd/tests/grants.rs +++ b/crates/brokerd/tests/grants.rs @@ -95,7 +95,7 @@ fn an_empty_directory_is_a_valid_empty_set() { let builtin: Vec<&str> = set.matchable().map(|g| g.id.as_str()).collect(); assert_eq!( builtin, - ["builtin-memory", "builtin-recall"], + ["builtin-memory", "builtin-recall", "builtin-ask-memory"], "only the built-ins" ); } diff --git a/crates/brokerd/tests/memory_tools.rs b/crates/brokerd/tests/memory_tools.rs index 1a0f4b0..ed84278 100644 --- a/crates/brokerd/tests/memory_tools.rs +++ b/crates/brokerd/tests/memory_tools.rs @@ -138,5 +138,5 @@ fn grant_files_cannot_name_the_memory_tools_or_take_a_built_in_id() { 1, "the built-ins are not listed as the owner's" ); - assert_eq!(with_shell.matchable().count(), 3); + assert_eq!(with_shell.matchable().count(), 4); } diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 0035552..10577e5 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone. | Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model | |---|---|---|---|---|---|---|---| +| M5c2/02-brokerd-ask-memory | 2026-09-28 | done | 1 | pass | none | Copied the given tests from `docs/plans/M5c2/files/crates/brokerd/tests/`: `ask_memory_tool.rs` (new) and new versions of `grants.rs` and `memory_tools.rs`. Step 2 (`cargo build --tests --keep-going -p brokerd`) showed exactly the 11 named failures: one E0432 `unresolved import brokerd::builtin::ASK_MEMORY`, one E0599 for `AskMemory` on `ToolArgs`, nine E0599 for `AskMemory` on `ToolName`; every other target compiled. `cargo test --no-fail-fast` on `grants` and `memory_tools` failed the two named lines (`an_empty_directory_is_a_valid_empty_set`, `grant_files_cannot_name_the_memory_tools_or_take_a_built_in_id`). Made edits A1 (`use bxcore::AskMemoryArgs`), A2 (`ToolName::AskMemory`), A3 (`ask_memory` in `parse`), A4 (`ask_memory` in `as_str`), A5 (`is_memory`), A6 (`ToolArgs::AskMemory`), A7 (`tool()`), A8 (`canonical_json`), A9 (`parse` decodes then runs `check`); B1 (module doc naming the three tools), B2 (`ASK_MEMORY` grant text, `BUILT_IN` sentence naming the three, `sources` with `builtin-ask-memory` last), C1 (`time_limit`), P1 (`matching` takes no constraints), R1 (`runner` no mounts/egress). `cargo check -p brokerd` clean. `cargo test` passes: 9 in `ask_memory_tool`, 17 in `grants`, 4 in `memory_tools`, 5 in `memory_port`, 8 in `runner`. `git diff --stat -- crates/brokerd/src` lists only the five files, not `grants.rs`/`container.rs`/`memory_port.rs`; the other-crates diff is empty. `make gate` printed `gate: ok` with 1381 tests (1380 in `cargo test --workspace`, plus the 1 ignored `end_to_end`; the +9 over the 1372 before is exactly the 9 new `ask_memory_tool` tests). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M5c2/01-wire-ask-memory | 2026-09-28 | done | 2 | pass | none | Copied the given test `crates/bxcore/tests/ask_memory_args.rs` from `docs/plans/M5c2/files/`. Step 2 (`cargo build --tests --keep-going -p bxcore`) showed exactly the two named failures: E0432 `unresolved import bxcore::AskMemoryArgs` and E0432 for `bxcore::memory::ASK_MEMORY`, `MAX_QUESTION`, `MAX_WORKSPACE_NAME`, `MAX_WORKSPACES`, `OWN_WORKSPACE`; every other target compiled. Made the edits exactly as the task gives: M1 (module doc naming `ask_memory` and the `[honcho]` content rule), M2 (`ASK_MEMORY`), M3 (`MAX_QUESTION` 2000, `MAX_WORKSPACES` 2, `MAX_WORKSPACE_NAME` 512, `OWN_WORKSPACE` "own"), M4 (`AskMemoryArgs { question, workspaces }` with `deny_unknown_fields`, `workspaces` `serde(default)`), M5 (`check` in the fixed order — the question empty then its length in characters, the number of names, then each name empty/form/repeat — and `asked` returning `["own"]` for an empty list), the private `workspace_name`, M6 (`MemoryCall` doc), X1 (`pub use memory::AskMemoryArgs` in lib.rs after the `log` line, leaving the `pub use memory::{...}` block). No `skip_serializing_if` on `AskMemoryArgs`; `grep -c 'skip_serializing_if'` prints 5. `cargo check -p bxcore` clean. `cargo test` passes: 8 in `ask_memory_args`, 6 in `memory_args`, 5 in `memory_wire`. `git diff --stat` lists only lib.rs (1 added) and memory.rs. `make gate` printed `gate: ok` with 1372 tests (1371 in `cargo test --workspace`, plus the 1 ignored `end_to_end` the gate runs separately; the +8 over the 1364 before is exactly the 8 new `ask_memory_args`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M5c1/15-memoryd-honcho-began | 2026-09-28 | done | 2 | pass | none | Copied the given tests from `docs/plans/M5c1/files/crates/memoryd/tests/`: the three new `honcho_ledger_began.rs` (5), `honcho_decide_began.rs` (4), `honcho_began_serve.rs` (2) and new versions of `honcho_ledger.rs`, `honcho_ledger_file.rs`, `honcho_decide.rs`, `honcho_turns.rs`, `honcho_naming.rs`, `honcho_serve.rs`, `honcho_mirror.rs`, `honcho_mirror_serve.rs`, `honcho_edits.rs`, `honcho_worker_more.rs`, `honcho_sync.rs` and the support `ledger.rs`, `turns.rs`, `worker.rs` (14 modified, 3 new). Step 2 (`cargo build --tests --keep-going -p memoryd`) showed the 14 named targets not compiling: E0061 (`decide` 5 args, `conclusion` 3), E0432 (`BEFORE_SYNC`), E0559 (`Unconclude` no `peer`), E0599 (no `began`/`begin`/`BeganNotFirst`/`Began`); every other target compiled. Made the edits exactly as the task gives: L1-L13 in `ledger.rs` (`Unconclude.peer`; `Record::Began`; the `BeganNotFirst`/`NotBegun` errors and their two Display lines; the `began`/`records` fields and init, the `first_not_began` check in `open`, the new `began()`, `conclusion` by peer, `check` refusing a second `Began`, `apply` counting records and setting `began`); D1-D5 in `decide.rs` (`Timestamp` import, `BEFORE_SYNC`, the doc, the `began` parameter, the first check on the `User` record's time); T1 (`turns.rs` passing `ledger().began()`); M1 (`mirror.rs` `Unconclude`'s `peer`); W1 (`worker.rs` `conclusion` with `peer`); A1-A2 (`start.rs` imports and `Opened::begin`, writing `Began` once when `began().is_none()`); S1-S2 (`serve.rs` doc and `opened.begin()` after `store.init`, before the sockets). `cargo check -p memoryd` clean after all edits; `cargo test -p memoryd` every suite passes; the other-crates `git diff --stat` is empty. `make gate` printed `gate: ok` with 1345 tests (1344 in `cargo test --workspace`, plus the 1 ignored `end_to_end` the gate runs separately; the +13 over baseline is exactly the 11 in the three new files, 1 more in `honcho_mirror.rs`, 1 more in `honcho_edits.rs`). One `loopd` `pointers` test failed once in a manual full-workspace run under load (`6 passed; 1 failed`) but passes in isolation and in a clean run without my change, and both `make gate` runs printed `gate: ok`: it is a load-dependent flake, not this task (loopd is untouched, the edit is all in memoryd). | ornith-1.5-35b-a3b | | M5c1/14-bxctl-memory-sync | 2026-09-28 | done | 1 | pass | none | Copied the two given tests from `docs/plans/M5c1/files/`: `crates/memoryd/tests/honcho_sync.rs` (from `honcho_sync.rs-task14`) and `crates/bxctl/tests/cli_memory_sync.rs`, and the skeleton `crates/memoryd/src/honcho/report.rs`. Step 2 showed exactly the named failures: `honcho_sync` with 5 (E0432 for `memoryd::honcho::report`, E0432 for `...::ledger::Failure`/`label`, two E0599 for `failures`, one E0599 for the missing `Sync`); `cli_memory_sync` with 6 (all the missing `Sync`). Made X1 (`MemoryCommand::Sync`), B1-B3 (`memory sync` parsed as `Ask::Admin(MemoryCommand::Sync, String::new())`, the module doc), U1 (the usage line), L1-L9 (`Failure { item, time, status, kind }`; `State::Failed { time, status, kind }`; the `fails: Vec` field, kept in `apply` on every `Failed` record; `failures()` oldest first via `filter_map`; renamed private `describe` to public `label`); R1 (`use crate::honcho::report`; `Mirror::report` under the ledger's lock); H1 (`pub mod report;`); S1/S2 (`Sync` answered with `mirror.report()` or `NOT_CONFIGURED`). Copied the skeleton. The task's U1 code block rendered every line with one extra leading space, but the actual `USAGE` literal uses two spaces before a command and six before its explanation (as `cli::usage_explains_the_speaker_flag` enforces), so I matched those. `cargo check -p memoryd` clean after filling the body: `counts()` for the first line, then when `failures()` is non-empty push `"\nfailed, oldest first:"` and each `format!("\n {} {} {}", time.to_rfc3339(), label(&item), kind)` with ` (HTTP {status})` appended when `status` is `Some`. `cargo test` all pass: `honcho_sync` 4, `honcho_ledger` 12, `honcho_ledger_file` 12, `honcho_worker` 12, `cli_memory_sync` 4, every other suite unchanged. `grep -n 'MemoryCommand::Sync'` prints two lines, `git diff --stat -- crates/loopd crates/brokerd crates/gatewayd Cargo.toml Cargo.lock` prints nothing. `make gate` printed `gate: ok` with 1332 tests (1331 in `cargo test --workspace`, plus the 1 ignored `end_to_end` the gate runs separately). No new dependency, so `Cargo.lock` is unchanged. | ornith-1.5-35b-a3b |