brokerd: the ledger raises a session's state by the stricter label

M5a task 21. The work is Ornith's, from the stopped run; the design
model checked it against the task file and committed it after finding
that the gate's failure came from its own reference worktree's stale
test binaries in the shared target/ (fixed with cargo clean; 944 tests).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-25 20:20:48 -07:00
co-authored by Claude Opus 5.5
parent f389a9a43a
commit 7b95937c88
3 changed files with 135 additions and 4 deletions
+11 -4
View File
@@ -6,7 +6,7 @@ use proto::{ApprovalAnswer, AuditEvent, DataClass, DenyReason, Timestamp, ToolRe
use super::{Answer, Answered, Call, Grants, Inner, Ledger};
use crate::approvals::Verdict;
use crate::policy::{Ask, Denial, SessionState};
use crate::policy::{Ask, Denial, Label, SessionState};
impl Ledger {
/// Re-decide an approval and record it.
@@ -159,10 +159,17 @@ impl Ledger {
};
match &response {
// 3. A result raises the state, then is recorded.
ToolResponse::Result { .. } => {
// 3. A result raises the state, then is recorded. The state follows the stricter of
// the grant's label and the result's own: `memoryd` can make a result stricter.
ToolResponse::Result {
class, untrusted, ..
} => {
let label = Label {
class: call.label.class.max(*class),
untrusted: call.label.untrusted || *untrusted,
};
let raised = match self.state_read(&guard, &call.session) {
Ok(state) => match guard.state.raise(&call.session, state, call.label) {
Ok(state) => match guard.state.raise(&call.session, state, label) {
Ok(raised) => raised,
Err(e) => {
(self.log)(&format!("brokerd: {e}"));
+123
View File
@@ -0,0 +1,123 @@
//! The ledger raises a session's state by the stricter of the grant's label and the result's own
//! (M5a spec, section 8). `memoryd` can make a `recall` result untrusted; if the session did not
//! follow, a clean session could recall a tainted note and then write it into `memory.md`. Do not
//! edit.
#[path = "support/build.rs"]
mod build;
#[path = "support/rig.rs"]
mod rig;
#[path = "support/sink.rs"]
mod sink;
#[path = "support/tmp.rs"]
mod tmp;
use brokerd::ledger::Call;
use brokerd::policy::{Label, SessionState};
use build::now;
use proto::{AuditEvent, CallId, DataClass, SessionId, ToolResponse};
use rig::Rig;
fn label(class: DataClass, untrusted: bool) -> Label {
Label { class, untrusted }
}
fn call(label: Label) -> Call {
Call {
session: SessionId::new("s1").unwrap(),
call: CallId(1),
decision: 0,
label,
}
}
fn result(label: Label) -> ToolResponse {
ToolResponse::Result {
content: "[1] memory-tainted · note".to_string(),
class: label.class,
untrusted: label.untrusted,
truncated: false,
}
}
fn state(rig: &Rig) -> SessionState {
rig.state().read(&SessionId::new("s1").unwrap()).unwrap()
}
/// The one result record's (class, untrusted, taint_after).
fn recorded(rig: &Rig) -> (DataClass, bool, DataClass) {
match rig.events().as_slice() {
[
AuditEvent::Result {
class,
untrusted,
taint_after,
..
},
] => (*class, *untrusted, *taint_after),
other => panic!("{other:?}"),
}
}
#[test]
fn a_recall_of_a_tainted_note_makes_a_clean_session_untrusted() {
let rig = Rig::new("label-recall");
let ledger = rig.ledger();
// `recall`'s built-in grant labels its results private and trusted; `memoryd` found a
// `memory-tainted` hit and said untrusted.
let found = label(DataClass::Private, true);
let answer = ledger.finish(
&call(label(DataClass::Private, false)),
result(found),
now(),
);
assert_eq!(answer, result(found), "passed on unchanged");
assert_eq!(
state(&rig),
SessionState {
taint: DataClass::Private,
untrusted: true
}
);
assert_eq!(
recorded(&rig),
(DataClass::Private, true, DataClass::Private)
);
}
#[test]
fn a_result_of_a_higher_class_than_its_grant_raises_the_taint() {
let rig = Rig::new("label-class");
let ledger = rig.ledger();
let found = label(DataClass::Secret, false);
ledger.finish(&call(label(DataClass::Public, false)), result(found), now());
assert_eq!(
state(&rig),
SessionState {
taint: DataClass::Secret,
untrusted: false
}
);
assert_eq!(
recorded(&rig),
(DataClass::Secret, false, DataClass::Secret)
);
}
#[test]
fn a_result_laxer_than_its_grant_still_raises_the_state_by_the_grant() {
let rig = Rig::new("label-laxer");
let ledger = rig.ledger();
ledger.finish(
&call(label(DataClass::Secret, true)),
result(label(DataClass::Public, false)),
now(),
);
assert_eq!(
state(&rig),
SessionState {
taint: DataClass::Secret,
untrusted: true
}
);
}
+1
View File
@@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone.
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|---|---|---|---|---|---|---|---|
| M5a/21-session-follows-result | 2026-09-25 | done (by the design model) | many | fail | The session added and then deleted diagnostic test files (`crates/bxctl/tests/zz_diag.rs`, `zz_diag2.rs`) and ran about 80 minutes of experiments instead of stopping when a test outside the task failed, as the task says. | The session's edits match the task file exactly and the copied `ledger_label.rs` is identical to the plan's; step 2 showed the two named tests failing, and after the edits `ledger_label`, `cargo test -p brokerd` and `-p bxctl` passed. The gate then failed in `bxctl`'s `args_os` with `NotFound`. The cause was the design model's: its reference worktree for tasks 21 and 22 shared this `target/` through a symlink, so test binaries built there had the worktree's absolute paths compiled in (`CARGO_BIN_EXE_*`, `CARGO_MANIFEST_DIR`), and Cargo, whose freshness check does not include the workspace's absolute path, kept them after the worktree was deleted. After `cargo clean`, `make gate` printed `gate: ok`, 944 tests. The owner stopped the run. | Ornith-1.5-35B-A3B (the session); design model (the commit) |
| M5a/20-review-ids | 2026-09-25 | done | 1 | pass | none | The two edits exactly as the task gives, both in `submit` in `crates/memoryd/src/review.rs`: the comment block "how many Waiting lines have an id starting format!(\"p-{day}-\"). The id is format!(\"p-{day}-{:04}\", taken + 1)." became "how many Waiting lines of `lines(store)?`, done or not, have an id starting format!(\"p-{day}-\"): an id is never used twice. The id is format!(\"p-{day}-{:04}\", taken + 1)."; and `let taken = waiting(store)?` became `let taken = lines(store)?`, the `.iter()`/`.filter(..)`/`.count()` after it unchanged. `waiting` untouched, since `list`/`accept`/`reject` still use it. Copied `crates/memoryd/tests/review_ids.rs` from `docs/plans/M5a/files/`. `cargo fmt --all`; step 2 showed exactly the two named tests failing (`a_write_staged_after_an_accepted_one_gets_a_new_id_and_can_be_accepted` expected `p-2026-09-24-0002` got `p-2026-09-24-0001`; `ids_count_every_write_staged_that_day_waiting_or_done` expected `p-2026-09-24-0004` got `p-2026-09-24-0002`); after the edits both pass and `cargo test -p memoryd` every suite passes; `make gate` printed `gate: ok`. `cargo test --workspace` reports 940 passed, plus the gate's separate loopd `end_to_end` ignored test = 941 total. No new dependency, so `Cargo.lock` is unchanged. | ? |
| M5a/19-no-forged-headers | 2026-09-25 | done | 2 | timed out (shell 2 min) | none | Two edits exactly as the task gives. `crates/proto/src/memory.rs`: the doc comment above `pub fn entry_problem` (the two lines "holding a line that is only the separator") became the three lines explaining a line starting with the separator, spaces aside, would read as a header in notes.md and a header says where an entry came from; and in the body, `if line.trim() == SEPARATOR { return Some(format!("an entry cannot hold a line that is only {SEPARATOR}")); }` became `if line.trim_start().starts_with(SEPARATOR) { return Some(format!("an entry cannot hold a line that starts with {SEPARATOR}")); }`, the for loop and closing braces unchanged. `crates/memoryd/src/notes.rs` `parse_header`: the `untrusted` match `Some(s) => *s == "untrusted"` became `Some(&"untrusted") => true, Some(_) => return None`, so a `noted` header whose last part is anything other than `untrusted` is not a header. Copied three test files from `docs/plans/M5a/files/`: `crates/proto/tests/memory_args.rs` and `crates/memoryd/tests/notes.rs` (new versions) and the new `crates/memoryd/tests/forgery.rs` (not the `-task02`/`-task06` files). `cargo fmt --all`. Step 2 showed exactly the three named tests failing (`entries_are_not_empty_not_too_long_and_hold_no_separator_line`, `a_noted_header_with_an_unknown_last_part_is_not_a_header`, `a_note_holding_a_header_line_is_refused_and_nothing_is_written`); the fourth, `recall_finds_no_identity_in_what_an_untrusted_session_wrote`, already passed. After the edits the step-2 command passed and `cargo test -p proto -p memoryd -p brokerd` every suite passed. `make gate` printed `gate: ok`; `cargo test --workspace` reported 938 passed and the gate's separate loopd `end_to_end` ignored test added 1, for 939 total. First `make gate` run hit the shell's 120 s timeout before finishing (the loopd daemon test needs more than 2 min); the second run completed with `gate: ok`. No new dependency, so `Cargo.lock` is unchanged. | Ornith-1.5-35B-A3B |
| M5a/18-repeated-code | 2026-09-25 | done | 2 | pass | none | The four edits exactly as the task gives: `edit.rs` added the private `claim(file, before, &mut named, old)` one blank line above `consolidate`, and replaced the four identical `Keep`/`Rewrite`/`Move`/`Drop` blocks (the `position` check plus the `named.insert` duplicate guard) with `claim(file, before, &mut named, &old)?;`, leaving the `New` arm untouched; `proto/src/memory.rs` added the private `entry(text, max)` one blank line above `impl RecallArgs`, and rewrote `MemoryArgs::check` to call `entry` with `?` instead of the nine `if let Some(problem) = entry_problem(..) { return Err(problem); }` blocks (the `Consolidate` arm's `map_err(|problem| problem.to_string())` became `map_err(str::to_string)`), `RecallArgs::check` unchanged; `brokerd/src/builtin.rs` replaced the two `GrantProblem {..}` inline closures in `grants` with a single `let problem = |error: String| GrantProblem {..}` and `map_err(|e| problem(e.to_string()))`; `bxctl/src/memory.rs` added `let unreachable = |e: &dyn std::fmt::Display| format!("cannot reach memoryd at {}: {e}", socket.display())` and used it for connect, the two `set_*_timeout` sets and the `write_frame` (the `memoryd did not answer` line was left as its own sentence). `cargo fmt --all`; the step-2 greps print 1, 0, 2, 1 in order; `cargo test -p memoryd -p proto -p brokerd -p bxctl` every suite passes; `make gate` printed `gate: ok` with 936 tests, unchanged. No new dependency, so `Cargo.lock` is unchanged. | Ornith-1.5-35B-A3B |