Files
boxmaker/docs/plans/M5a/12-brokerd-memory-port.md
kyleandClaude Opus 5.5 de1032157b Plan M5a: local memory in 15 tasks
memoryd (files, the check, edits, journal, index, recall, serving),
the memory tools and built-in grants in brokerd, the memory block in
loopd, bxctl reindex and memory, and the device checks with memoryd.

Each task was built as a reference and gated. Each was replayed on its
own at its end state (854 to 936 tests) and mutation-checked. Each
skeleton compiles and fails its tests. Every todo comment was read
beside its signature.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 15:32:54 -07:00

7.3 KiB

M5a task 12: decided memory calls go to memoryd, and labels only get stricter

Branch: m5a (run git switch m5a; git status --short must be empty, otherwise stop) Commit subject: brokerd: memory calls to memoryd with the session's state; labels only stricter

Goal

M5a spec, section 8:

  • A decided recall or memory call goes to memoryd's brokerd socket as a memory_call, with the session's taint and untrusted flag as brokerd held them when it decided. Every other call goes to the configured runtime, as before (Routed).
  • memoryd knows the label of what it returns (a recall hit from an untrusted session). The result keeps the stricter of that and the grant's label: the higher class, and untrusted if either says so. A runtime can never make a result laxer.
  • The container runtime refuses a memory tool rather than start a container for it.
  • memoryd not answering in 30 seconds is a failed result with a fixed sentence and a runbook pointer.

To carry the session's state, Decision gains a private field (decide and redecide already receive the state), and RunSpec gains one too. Runtime gains run_labelled, with a default that calls run and returns no label, so no existing runtime changes.

Files

  • Copy (skeleton): crates/brokerd/src/memory_port.rs
  • Copy (tests): crates/brokerd/tests/memory_port.rs, and a new version of the given crates/brokerd/tests/support/rig.rs (its Config literal gains memory)
  • Append: docs/plans/M5a/files/runbook-memoryd-unreachable.md to the end of docs/runbook.md (a blank line before it)
  • Modify: crates/brokerd/src/lib.rs (pub mod memory_port; after pub mod ledger;), crates/brokerd/src/policy/mod.rs, crates/brokerd/src/runner.rs, crates/brokerd/src/container.rs, crates/brokerd/src/config.rs, crates/brokerd/src/main.rs, docs/implementer-log.md

The skeleton

Written: UNREACHABLE, MEMORY_WAIT, MemoryPort and new, Routed, and both run methods (they call run_labelled). Fill: MemoryPort::ask, MemoryPort::run_labelled, Routed::run_labelled.

policy/mod.rs, exactly

  1. In struct Decision, after matched: Matched, add:
    /// The session's state when the call was decided; `memoryd` needs it (M5a spec, section 8).
    state: SessionState,

(Only Decision: Ask is unchanged.) 2. In impl Decision, after request() add:

    pub fn state(&self) -> SessionState {
        self.state
    }
  1. In decide, the Mode::Auto => Outcome::Allowed(Decision { request, args, matched, }) literal gains state, after matched,; in redecide, the Ok(Decision { … matched, }) literal gains state, after matched,. Both functions already have state as a parameter.

runner.rs, exactly

  1. use crate::policy::Decision; becomes use crate::policy::{Decision, Label, SessionState};.
  2. In struct RunSpec, after egress: Option<Vec<String>>, add state: SessionState,; in impl RunSpec, after egress() add:
    /// The session's state when the call was decided.
    pub fn state(&self) -> SessionState {
        self.state
    }
  1. In trait Runtime, after fn run(…); add:

    /// `run`, and a label for what came back when the runtime knows one (`memoryd` does: a
    /// `recall` hit from an untrusted session). The runner keeps the stricter of this and the
    /// grant's label. Container runtimes know none.
    fn run_labelled(&self, spec: &RunSpec) -> Result<(RunOutput, Option<Label>), RunError> {
        self.run(spec).map(|output| (output, None))
    }
  1. In run, the RunSpec { … } literal gains state: decision.state(), after egress,, and
    match runtime.run(&spec) {
        Ok(output) => ToolResponse::Result {
            content: output.content,
            class: label.class,
            untrusted: label.untrusted,
            truncated: output.truncated,
        },

becomes

    match runtime.run_labelled(&spec) {
        Ok((output, found)) => {
            // Labels only get stricter: the higher class, and untrusted if either says so.
            let (class, untrusted) = match found {
                Some(found) => (
                    label.class.max(found.class),
                    label.untrusted || found.untrusted,
                ),
                None => (label.class, label.untrusted),
            };
            ToolResponse::Result {
                content: output.content,
                class,
                untrusted,
                truncated: output.truncated,
            }
        }

(the Err arm below it is unchanged).

container.rs, config.rs, main.rs, exactly

  • container.rs: after pub type Log = …; add

/// A memory tool that reached the container runtime: fixed, it reaches the model.
pub const IN_MEMORYD: &str = "the memory tools run in memoryd, not in a container";

and at the top of Podman's fn run, before let n = self.next.fetch_add(…), add

        // `Routed` sends the memory tools to `memoryd`; one here is refused, never run.
        if spec.tool().is_memory() {
            return Err(RunError::Unavailable(IN_MEMORYD.to_string()));
        }
  • config.rs: before fn default_podman() add
/// `[memory]`: where `memoryd` takes the decided `recall` and `memory` calls. The memory tools are
/// always on, so the section is optional; an empty path means the default under `home`.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
#[serde(deny_unknown_fields, default)]
pub struct Memory {
    pub socket: PathBuf,
}

in struct Config, after the gateway field, add #[serde(default)] and pub memory: Memory,; and before pub fn audit_dir add

    /// `memoryd`'s socket for `brokerd`.
    pub fn memory_socket(&self) -> PathBuf {
        if self.memory.socket.as_os_str().is_empty() {
            self.paths.home.join("run/broker-memory/memory.sock")
        } else {
            self.memory.socket.clone()
        }
    }
  • main.rs: after use brokerd::container::Podman; add use brokerd::memory_port::{MEMORY_WAIT, MemoryPort, Routed};, and just before let started = match serve::start(cfg, accept_break, runtime, log) { add
    // The memory tools go to `memoryd`, whatever runs the others (M5a spec, section 8).
    let runtime: Box<dyn Runtime> = Box::new(Routed {
        memory: MemoryPort::new(cfg.memory_socket(), MEMORY_WAIT),
        other: runtime,
    });

Steps

  • 1. Copy the skeleton, the test and the new rig.rs; append the runbook entry; make the edits, one file at a time, with cargo check -p brokerd after each.
  • 2. See it fail. cargo test -p brokerd --test memory_port: 4 failed, 1 passed (the one that never reaches memoryd).
  • 3. Fill the three functions. cargo fmt --all.
  • 4. See it pass. The same command, three times: 5 passed. Then cargo test -p brokerd: every suite passes.
  • 5. Run the gate. make gate: gate: ok, 5 more tests than before (about 929).
  • 6. Log and commit. git add -A crates/brokerd docs/runbook.md docs/implementer-log.md Cargo.lock && git commit

Done when

  • make gate prints gate: ok.

Stop and report if

  • A policy compile-fail doc test starts to compile, or any other brokerd test fails.