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

203 lines
7.3 KiB
Markdown

# 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:
```rust
/// 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:
```rust
pub fn state(&self) -> SessionState {
self.state
}
```
3. 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:
```rust
/// The session's state when the call was decided.
pub fn state(&self) -> SessionState {
self.state
}
```
3. In `trait Runtime`, after `fn run(…);` add:
```rust
/// `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))
}
```
4. In `run`, the `RunSpec { … }` literal gains `state: decision.state(),` after `egress,`, and
```rust
match runtime.run(&spec) {
Ok(output) => ToolResponse::Result {
content: output.content,
class: label.class,
untrusted: label.untrusted,
truncated: output.truncated,
},
```
becomes
```rust
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
```rust
/// 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
```rust
// `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
```rust
/// `[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
```rust
/// `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
```rust
// 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.