brokerd: policy and ledger as modules, before the memory tools

Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
2026-09-25 16:41:35 -07:00
committed by kyle
parent 41f8329afe
commit 861ff7d06a
5 changed files with 401 additions and 378 deletions
+204
View File
@@ -0,0 +1,204 @@
//! Answering an approval, and recording a call's result.
use std::sync::MutexGuard;
use proto::{ApprovalAnswer, AuditEvent, DataClass, DenyReason, Timestamp, ToolResponse};
use super::{Answer, Answered, Call, Grants, Inner, Ledger};
use crate::approvals::Verdict;
use crate::policy::{Ask, Denial, SessionState};
impl Ledger {
/// Re-decide an approval and record it.
pub fn answer(
&self,
ask: Ask,
decision: u64,
answer: Answer,
grants: &Grants,
now: Timestamp,
) -> Answered {
// 1. Lock fails → step fails.
let mut guard = match self.lock() {
Some(guard) => guard,
None => return Self::audit_unavailable(),
};
// 2. Read the state of the ask's session.
let session = ask.request().session.clone();
let call = ask.request().call;
let state = self.state_read(&guard, &session);
let recorded = match &state {
Ok(state) => *state,
Err(()) => SessionState {
taint: DataClass::Secret,
untrusted: true,
},
};
// 3. The answer and its fields.
let (answer, by, post, reason) = match &answer {
Answer::Approved { by, post } => {
(ApprovalAnswer::Approved, by.clone(), post.clone(), None)
}
Answer::Refused { by, post, reason } => (
ApprovalAnswer::Refused,
by.clone(),
post.clone(),
reason.clone(),
),
Answer::Expired => (ApprovalAnswer::Expired, None, None, None),
};
// 4. The re-decision (approved only); every other answer is a denial.
let redecide = match (answer, grants) {
(ApprovalAnswer::Approved, Ok(set)) => match &state {
Ok(state) => crate::policy::redecide(ask, set, *state, now),
Err(()) => Err(Denial::new(DenyReason::StateUnreadable)),
},
_ => Err(Denial::new(DenyReason::GrantsInvalid)),
};
// 5. The grant fields, then the verdict and outcome together.
let (grant, grant_sha256) = match &redecide {
Ok(decision) => (
Some(decision.grant().to_string()),
Some(decision.grant_sha256()),
),
Err(_) => (None, None),
};
let (verdict, outcome) = match (answer, redecide) {
(ApprovalAnswer::Approved, Ok(decision)) => {
let outcome = self.outcome_for(&decision, grants);
(Verdict::Run(Box::new(decision)), outcome)
}
(ApprovalAnswer::Approved, Err(denial)) => Self::denied(denial.reason),
(ApprovalAnswer::Refused, _) => Self::denied(DenyReason::ApprovalRefused),
(ApprovalAnswer::Expired, _) => Self::denied(DenyReason::ApprovalExpired),
};
// 6. Record the approval, or step fails.
match self.append(
&mut guard,
now,
AuditEvent::Approval {
session,
call,
decision,
answer,
by,
post,
reason,
outcome: outcome.clone(),
grant,
grant_sha256,
taint: recorded.taint,
untrusted: recorded.untrusted,
},
) {
Ok(_) => Answered { verdict, outcome },
Err(()) => Self::audit_unavailable(),
}
}
/// Raise the state, hash the response, and record a result, or stop the ledger on failure.
fn record_result(
&self,
guard: &mut MutexGuard<'_, Inner>,
call: &Call,
now: Timestamp,
status: proto::ResultStatus,
taint_after: DataClass,
response: &ToolResponse,
) -> ToolResponse {
let (sha256, bytes) = match response {
ToolResponse::Result { content, .. } => match proto::sha256(content.as_bytes()) {
Ok(sha) => (sha, u64::try_from(content.len()).unwrap_or(u64::MAX)),
Err(_) => return Self::not_recorded(),
},
ToolResponse::Failed { message } => match proto::sha256(message.as_bytes()) {
Ok(sha) => (sha, u64::try_from(message.len()).unwrap_or(u64::MAX)),
Err(_) => return Self::not_recorded(),
},
_ => return Self::not_recorded(),
};
let (class, untrusted, truncated) = match response {
ToolResponse::Result {
class,
untrusted,
truncated,
..
} => (*class, *untrusted, *truncated),
ToolResponse::Failed { .. } => (call.label.class, call.label.untrusted, false),
_ => return Self::not_recorded(),
};
let event = AuditEvent::Result {
session: call.session.clone(),
call: call.call,
decision: call.decision,
status,
class,
untrusted,
truncated,
bytes,
sha256,
taint_after,
};
match self.append(guard, now, event) {
Ok(_) => response.clone(),
Err(()) => Self::not_recorded(),
}
}
/// Raise the state and record a result.
pub fn finish(&self, call: &Call, response: ToolResponse, now: Timestamp) -> ToolResponse {
// 1. Lock fails → cannot record.
let mut guard = match self.lock() {
Some(guard) => guard,
None => return Self::not_recorded(),
};
match &response {
// 3. A result raises the state, then is recorded.
ToolResponse::Result { .. } => {
let raised = match self.state_read(&guard, &call.session) {
Ok(state) => match guard.state.raise(&call.session, state, call.label) {
Ok(raised) => raised,
Err(e) => {
(self.log)(&format!("brokerd: {e}"));
return Self::not_recorded();
}
},
Err(()) => return Self::not_recorded(),
};
self.record_result(
&mut guard,
call,
now,
proto::ResultStatus::Result,
raised.taint,
&response,
)
}
// 4. A failure changes no state, recorded by its message.
ToolResponse::Failed { .. } => {
let taint_after = match self.state_read(&guard, &call.session) {
Ok(state) => state.taint,
Err(()) => DataClass::Secret,
};
self.record_result(
&mut guard,
call,
now,
proto::ResultStatus::Failed,
taint_after,
&response,
)
}
// 5. The runner never returns these; nothing to record.
ToolResponse::Denied { .. } | ToolResponse::PendingApproval { .. } => {
Self::not_recorded()
}
}
}
}
@@ -2,11 +2,13 @@
//! that hold it. Without the one lock, two results for one session could each read `private` and
//! the second write would put the taint back down; after any failed append or panic while the lock was held, the ledger refuses every later step, and only the next start's check puts a partial line on disk right.
mod answer;
use std::sync::{Mutex, MutexGuard};
use proto::{
ApprovalAnswer, AuditEvent, CallId, DataClass, DecisionRecord, DenyReason, GrantProblem, Mode,
SessionId, Timestamp, ToolRequest, ToolResponse,
AuditEvent, CallId, DataClass, DecisionRecord, DenyReason, GrantProblem, Mode, SessionId,
Timestamp, ToolRequest, ToolResponse,
};
use crate::approvals::Verdict;
@@ -291,197 +293,4 @@ impl Ledger {
Err(()) => Decided::Denied(DenyReason::AuditUnavailable),
}
}
/// Re-decide an approval and record it.
pub fn answer(
&self,
ask: Ask,
decision: u64,
answer: Answer,
grants: &Grants,
now: Timestamp,
) -> Answered {
// 1. Lock fails → step fails.
let mut guard = match self.lock() {
Some(guard) => guard,
None => return Self::audit_unavailable(),
};
// 2. Read the state of the ask's session.
let session = ask.request().session.clone();
let call = ask.request().call;
let state = self.state_read(&guard, &session);
let recorded = match &state {
Ok(state) => *state,
Err(()) => SessionState {
taint: DataClass::Secret,
untrusted: true,
},
};
// 3. The answer and its fields.
let (answer, by, post, reason) = match &answer {
Answer::Approved { by, post } => {
(ApprovalAnswer::Approved, by.clone(), post.clone(), None)
}
Answer::Refused { by, post, reason } => (
ApprovalAnswer::Refused,
by.clone(),
post.clone(),
reason.clone(),
),
Answer::Expired => (ApprovalAnswer::Expired, None, None, None),
};
// 4. The re-decision (approved only); every other answer is a denial.
let redecide = match (answer, grants) {
(ApprovalAnswer::Approved, Ok(set)) => match &state {
Ok(state) => crate::policy::redecide(ask, set, *state, now),
Err(()) => Err(Denial::new(DenyReason::StateUnreadable)),
},
_ => Err(Denial::new(DenyReason::GrantsInvalid)),
};
// 5. The grant fields, then the verdict and outcome together.
let (grant, grant_sha256) = match &redecide {
Ok(decision) => (
Some(decision.grant().to_string()),
Some(decision.grant_sha256()),
),
Err(_) => (None, None),
};
let (verdict, outcome) = match (answer, redecide) {
(ApprovalAnswer::Approved, Ok(decision)) => {
let outcome = self.outcome_for(&decision, grants);
(Verdict::Run(Box::new(decision)), outcome)
}
(ApprovalAnswer::Approved, Err(denial)) => Self::denied(denial.reason),
(ApprovalAnswer::Refused, _) => Self::denied(DenyReason::ApprovalRefused),
(ApprovalAnswer::Expired, _) => Self::denied(DenyReason::ApprovalExpired),
};
// 6. Record the approval, or step fails.
match self.append(
&mut guard,
now,
AuditEvent::Approval {
session,
call,
decision,
answer,
by,
post,
reason,
outcome: outcome.clone(),
grant,
grant_sha256,
taint: recorded.taint,
untrusted: recorded.untrusted,
},
) {
Ok(_) => Answered { verdict, outcome },
Err(()) => Self::audit_unavailable(),
}
}
/// Raise the state, hash the response, and record a result, or stop the ledger on failure.
fn record_result(
&self,
guard: &mut MutexGuard<'_, Inner>,
call: &Call,
now: Timestamp,
status: proto::ResultStatus,
taint_after: DataClass,
response: &ToolResponse,
) -> ToolResponse {
let (sha256, bytes) = match response {
ToolResponse::Result { content, .. } => match proto::sha256(content.as_bytes()) {
Ok(sha) => (sha, u64::try_from(content.len()).unwrap_or(u64::MAX)),
Err(_) => return Self::not_recorded(),
},
ToolResponse::Failed { message } => match proto::sha256(message.as_bytes()) {
Ok(sha) => (sha, u64::try_from(message.len()).unwrap_or(u64::MAX)),
Err(_) => return Self::not_recorded(),
},
_ => return Self::not_recorded(),
};
let (class, untrusted, truncated) = match response {
ToolResponse::Result {
class,
untrusted,
truncated,
..
} => (*class, *untrusted, *truncated),
ToolResponse::Failed { .. } => (call.label.class, call.label.untrusted, false),
_ => return Self::not_recorded(),
};
let event = AuditEvent::Result {
session: call.session.clone(),
call: call.call,
decision: call.decision,
status,
class,
untrusted,
truncated,
bytes,
sha256,
taint_after,
};
match self.append(guard, now, event) {
Ok(_) => response.clone(),
Err(()) => Self::not_recorded(),
}
}
/// Raise the state and record a result.
pub fn finish(&self, call: &Call, response: ToolResponse, now: Timestamp) -> ToolResponse {
// 1. Lock fails → cannot record.
let mut guard = match self.lock() {
Some(guard) => guard,
None => return Self::not_recorded(),
};
match &response {
// 3. A result raises the state, then is recorded.
ToolResponse::Result { .. } => {
let raised = match self.state_read(&guard, &call.session) {
Ok(state) => match guard.state.raise(&call.session, state, call.label) {
Ok(raised) => raised,
Err(e) => {
(self.log)(&format!("brokerd: {e}"));
return Self::not_recorded();
}
},
Err(()) => return Self::not_recorded(),
};
self.record_result(
&mut guard,
call,
now,
proto::ResultStatus::Result,
raised.taint,
&response,
)
}
// 4. A failure changes no state, recorded by its message.
ToolResponse::Failed { .. } => {
let taint_after = match self.state_read(&guard, &call.session) {
Ok(state) => state.taint,
Err(()) => DataClass::Secret,
};
self.record_result(
&mut guard,
call,
now,
proto::ResultStatus::Failed,
taint_after,
&response,
)
}
// 5. The runner never returns these; nothing to record.
ToolResponse::Denied { .. } | ToolResponse::PendingApproval { .. } => {
Self::not_recorded()
}
}
}
}
+187
View File
@@ -0,0 +1,187 @@
//! Matching a call against the grants: which grants cover it, and which one wins.
use super::{Label, Matched, SessionState};
use crate::args::{ToolArgs, host_matches, inside};
use crate::grants::GrantSet;
use proto::{DataClass, Grant, Hash32, Mode, Timestamp};
/// The grants left after matching, and the two flags that pick the denial reason when none are
/// left.
pub(super) struct MatchResult {
pub(super) left: Vec<Candidate>,
pub(super) expired: bool,
pub(super) tainted: bool,
}
#[derive(Clone)]
pub(super) struct Candidate {
pub(super) id: String,
pub(super) grant_sha256: Hash32,
pub(super) mode: Mode,
pub(super) matched_path: Option<String>,
pub(super) paths: Vec<String>,
pub(super) hosts: Vec<String>,
pub(super) expires: Option<Timestamp>,
pub(super) result_class: DataClass,
pub(super) untrusted: bool,
}
/// Match a call against every grant, in id order, keeping the ones that still stand.
pub(super) fn match_grants(
args: &ToolArgs,
grants: &GrantSet,
state: SessionState,
now: Timestamp,
) -> MatchResult {
let mut left: Vec<Candidate> = Vec::new();
let mut expired = false;
let mut tainted = false;
for loaded in grants.grants() {
// M1: only grants for this tool play a part.
if loaded.grant.tool != args.tool().as_str() {
continue;
}
// M2: does this grant cover the arguments, and with which path (if any)?
let Some(matched_path) = covers(args, &loaded.grant) else {
continue;
};
// M3: a grant that would match but is past its expiry or beyond the session's taint does
// not stand. Remember each reason, so the denial can name the right one.
let expired_at = loaded.grant.expires.is_some_and(|at| now >= at);
let too_tainted = state.taint > loaded.grant.max_taint;
if expired_at && !too_tainted {
expired = true;
}
if too_tainted && !expired_at {
tainted = true;
}
if !expired_at && !too_tainted {
left.push(Candidate {
id: loaded.id.clone(),
grant_sha256: loaded.sha256,
mode: loaded.grant.mode,
matched_path,
paths: loaded.grant.constraints.paths.clone(),
hosts: loaded.grant.constraints.hosts.clone(),
expires: loaded.grant.expires,
result_class: loaded.grant.result_class,
untrusted: loaded.grant.untrusted,
});
}
}
MatchResult {
left,
expired,
tainted,
}
}
/// Whether the grant covers the call, and the longest of its paths that holds the call. `None`
/// means the grant does not cover the call at all.
fn covers(args: &ToolArgs, grant: &Grant) -> Option<Option<String>> {
match args {
ToolArgs::ReadFile { path } => best_path(grant, path, true),
ToolArgs::WriteFile { path, .. } => best_path(grant, path, false),
ToolArgs::Shell { cwd: None, .. } => {
if grant.constraints.paths.is_empty() {
Some(None)
} else {
None
}
}
ToolArgs::Shell { cwd: Some(cwd), .. } => best_path(grant, cwd, true),
ToolArgs::HttpFetch(target) => {
if grant
.constraints
.hosts
.iter()
.any(|pattern| host_matches(pattern, target.host()))
{
Some(None)
} else {
None
}
}
}
}
/// The longest granted path `p` such that `inside(p, target)`. For writes, a path equal to the
/// argument does not count.
fn best_path(grant: &Grant, target: &str, itself_counts: bool) -> Option<Option<String>> {
let mut best: Option<String> = None;
for candidate in &grant.constraints.paths {
if !inside(candidate, target) {
continue;
}
if !itself_counts && candidate == target {
continue;
}
if best
.as_ref()
.is_none_or(|found| candidate.len() > found.len())
{
best = Some(candidate.clone());
}
}
// `None` means no path holds, so the grant does not cover the call; a held path becomes
// `Some(Some(p))` so `covers` can tell it apart from a covered call with no path.
best.map(Some)
}
/// Build the winning grant's `Matched`, with the label over every standing grant.
pub(super) fn build_matched(left: &[Candidate], winner: &Candidate) -> Matched {
Matched {
grant: winner.id.clone(),
grant_sha256: winner.grant_sha256,
matched_path: winner.matched_path.clone(),
paths: winner.paths.clone(),
hosts: winner.hosts.clone(),
expires: winner.expires,
label: combined_label(left),
}
}
/// The most restrictive standing grant, then the longest matched path, then the lowest id.
pub(super) fn winner(candidates: &[Candidate]) -> Option<Candidate> {
for &mode in [Mode::Deny, Mode::Ask, Mode::Auto].iter() {
let mut best: Option<&Candidate> = None;
for candidate in candidates.iter().filter(|c| c.mode == mode) {
best = Some(match best {
Some(found) if !better(candidate, found) => found,
_ => candidate,
});
}
if let Some(found) = best {
return Some(found.clone());
}
}
None
}
/// Is `a` a better winner than `b`: a longer matched path, or the same path and a lower id.
fn better(a: &Candidate, b: &Candidate) -> bool {
let (la, lb) = (
a.matched_path.as_ref().map_or(0, String::len),
b.matched_path.as_ref().map_or(0, String::len),
);
match la.cmp(&lb) {
std::cmp::Ordering::Greater => true,
std::cmp::Ordering::Less => false,
std::cmp::Ordering::Equal => a.id < b.id,
}
}
/// The label over every standing grant: the highest data class, and untrusted if any says so.
fn combined_label(candidates: &[Candidate]) -> Label {
let mut class = DataClass::Public;
for candidate in candidates {
if candidate.result_class > class {
class = candidate.result_class;
}
}
let untrusted = candidates.iter().any(|c| c.untrusted);
Label { class, untrusted }
}
@@ -82,9 +82,12 @@
//! }
//! ```
use crate::args::{self, ToolArgs, ToolName, host_matches, inside};
mod matching;
use crate::args::{self, ToolArgs, ToolName};
use crate::grants::GrantSet;
use proto::{DataClass, DenyReason, Grant, Hash32, Mode, Timestamp, ToolRequest};
use matching::{MatchResult, build_matched, match_grants, winner};
use proto::{DataClass, DenyReason, Hash32, Mode, Timestamp, ToolRequest};
/// The session's taint, and whether its results are untrusted. Only `taint` shapes a decision.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -315,184 +318,3 @@ pub fn redecide(
}),
}
}
/// The grants left after matching, and the two flags that pick the denial reason when none are
/// left.
struct MatchResult {
left: Vec<Candidate>,
expired: bool,
tainted: bool,
}
#[derive(Clone)]
struct Candidate {
id: String,
grant_sha256: Hash32,
mode: Mode,
matched_path: Option<String>,
paths: Vec<String>,
hosts: Vec<String>,
expires: Option<Timestamp>,
result_class: DataClass,
untrusted: bool,
}
/// Match a call against every grant, in id order, keeping the ones that still stand.
fn match_grants(
args: &ToolArgs,
grants: &GrantSet,
state: SessionState,
now: Timestamp,
) -> MatchResult {
let mut left: Vec<Candidate> = Vec::new();
let mut expired = false;
let mut tainted = false;
for loaded in grants.grants() {
// M1: only grants for this tool play a part.
if loaded.grant.tool != args.tool().as_str() {
continue;
}
// M2: does this grant cover the arguments, and with which path (if any)?
let Some(matched_path) = covers(args, &loaded.grant) else {
continue;
};
// M3: a grant that would match but is past its expiry or beyond the session's taint does
// not stand. Remember each reason, so the denial can name the right one.
let expired_at = loaded.grant.expires.is_some_and(|at| now >= at);
let too_tainted = state.taint > loaded.grant.max_taint;
if expired_at && !too_tainted {
expired = true;
}
if too_tainted && !expired_at {
tainted = true;
}
if !expired_at && !too_tainted {
left.push(Candidate {
id: loaded.id.clone(),
grant_sha256: loaded.sha256,
mode: loaded.grant.mode,
matched_path,
paths: loaded.grant.constraints.paths.clone(),
hosts: loaded.grant.constraints.hosts.clone(),
expires: loaded.grant.expires,
result_class: loaded.grant.result_class,
untrusted: loaded.grant.untrusted,
});
}
}
MatchResult {
left,
expired,
tainted,
}
}
/// Whether the grant covers the call, and the longest of its paths that holds the call. `None`
/// means the grant does not cover the call at all.
fn covers(args: &ToolArgs, grant: &Grant) -> Option<Option<String>> {
match args {
ToolArgs::ReadFile { path } => best_path(grant, path, true),
ToolArgs::WriteFile { path, .. } => best_path(grant, path, false),
ToolArgs::Shell { cwd: None, .. } => {
if grant.constraints.paths.is_empty() {
Some(None)
} else {
None
}
}
ToolArgs::Shell { cwd: Some(cwd), .. } => best_path(grant, cwd, true),
ToolArgs::HttpFetch(target) => {
if grant
.constraints
.hosts
.iter()
.any(|pattern| host_matches(pattern, target.host()))
{
Some(None)
} else {
None
}
}
}
}
/// The longest granted path `p` such that `inside(p, target)`. For writes, a path equal to the
/// argument does not count.
fn best_path(grant: &Grant, target: &str, itself_counts: bool) -> Option<Option<String>> {
let mut best: Option<String> = None;
for candidate in &grant.constraints.paths {
if !inside(candidate, target) {
continue;
}
if !itself_counts && candidate == target {
continue;
}
if best
.as_ref()
.is_none_or(|found| candidate.len() > found.len())
{
best = Some(candidate.clone());
}
}
// `None` means no path holds, so the grant does not cover the call; a held path becomes
// `Some(Some(p))` so `covers` can tell it apart from a covered call with no path.
best.map(Some)
}
/// Build the winning grant's `Matched`, with the label over every standing grant.
fn build_matched(left: &[Candidate], winner: &Candidate) -> Matched {
Matched {
grant: winner.id.clone(),
grant_sha256: winner.grant_sha256,
matched_path: winner.matched_path.clone(),
paths: winner.paths.clone(),
hosts: winner.hosts.clone(),
expires: winner.expires,
label: combined_label(left),
}
}
/// The most restrictive standing grant, then the longest matched path, then the lowest id.
fn winner(candidates: &[Candidate]) -> Option<Candidate> {
for &mode in [Mode::Deny, Mode::Ask, Mode::Auto].iter() {
let mut best: Option<&Candidate> = None;
for candidate in candidates.iter().filter(|c| c.mode == mode) {
best = Some(match best {
Some(found) if !better(candidate, found) => found,
_ => candidate,
});
}
if let Some(found) = best {
return Some(found.clone());
}
}
None
}
/// Is `a` a better winner than `b`: a longer matched path, or the same path and a lower id.
fn better(a: &Candidate, b: &Candidate) -> bool {
let (la, lb) = (
a.matched_path.as_ref().map_or(0, String::len),
b.matched_path.as_ref().map_or(0, String::len),
);
match la.cmp(&lb) {
std::cmp::Ordering::Greater => true,
std::cmp::Ordering::Less => false,
std::cmp::Ordering::Equal => a.id < b.id,
}
}
/// The label over every standing grant: the highest data class, and untrusted if any says so.
fn combined_label(candidates: &[Candidate]) -> Label {
let mut class = DataClass::Public;
for candidate in candidates {
if candidate.result_class > class {
class = candidate.result_class;
}
}
let untrusted = candidates.iter().any(|c| c.untrusted);
Label { class, untrusted }
}
+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/01-brokerd-split | 2026-09-24 | done | 1 | pass | none | Pure move, no line of code edited. Moved `crates/brokerd/src/policy.rs` to `policy/mod.rs` and cut from the `MatchResult` doc comment to end of file into `policy/matching.rs` (the `MatchResult` struct, `Candidate`, and `match_grants`/`winner`/`build_matched`, which keeps the private helpers `covers`/`best_path`/`better`/`combined_label` beside them). Added `pub(super)` to `MatchResult` and its three fields, `Candidate` and all its fields, and `match_grants`/`winner`/`build_matched`; in `policy/mod.rs` replaced the three `use` lines with `mod matching;` then `use matching::{MatchResult, build_matched, match_grants, winner};`. Moved `ledger/answer` through `finish` into `ledger/answer.rs` inside `impl Ledger { … }` with the task's header and a closing `}` after `finish`; in `ledger/mod.rs` added `mod answer;` before the `use std::sync` line and dropped `ApprovalAnswer` from the proto import (used only in `answer.rs`). `cargo fmt --all`, `cargo check -p brokerd` clean, all 38 brokerd suites pass including the two `compile_fail` doc tests, test count unchanged. `wc -l` shows 187/320/204/296, all under 500. `make gate` prints `gate: ok` on the first run. | ? |
| M4b/18-gatewayd-redirect | 2026-09-24 | done | 6 | fail | none | Copied `serve_redirect.rs` and a new `serve_approvals.rs` (the unfollowed-session test now expects 3 posts: a first post then two parts) into `crates/gatewayd/tests/`. In `serve/mod.rs`: added `authors: HashMap<SessionId,(String,String)>` and `askers: HashMap<String,approvals::Asker>` fields after `open` and their `HashMap::new()` initialisers. In `serve/approvals.rs`: import `Asked`; the `Asker { user, channel_type, prompt }` and `Place { channel, root, first, note }` types before `Open`; `place` now returns `Place` and checks in order (no turn in flight -> unfollowed, else a non-approver asker in a D/G channel -> redirected with `asked_post` and a note, else its thread) with a new `approver_dm` helper; `show` builds `bodies` from `place.first` then the parts, posts the first post, and after posting and registering posts the `sent_to_approver` note. In `serve/handle.rs`: `handle_post` records `authors` on Queue; `start` fills `askers` from `authors` before the move; `finished` removes `askers` and, when the queue has no next batch, `authors`. `cargo fmt --all` then `cargo check` clean. `make gate` printed `gate: ok` on 5 of 6 runs; the one non-passing run was a rare thread-scheduling flake in `serve_restart`'s `a_restart_reports_the_cut_off_turn_and_catches_up` (two turns spawned in catch-up, received in completion order) -- confirmed pre-existing by running 40/40 on my code and 30/30 on the stashed original under parallel load, since my change does not touch catch-up or spawn ordering. | ? |
| M4b/17-gatewayd-asked-texts | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/gatewayd/tests/approvals_asked.rs` into `crates/gatewayd/tests/`. In `approvals/text.rs` appended after `with_ending` exactly as the task gives: `PROMPT_SHOWN` (2_000), the `Asked { by, channel_type, prompt }` struct, `asked_post` (route on `channel_type` for the place string, escape the prompt with `escape_model_text`, count chars with `chars()` not `len()`, show the first `PROMPT_SHOWN` and say how many more), `unfollowed_post` (session through `code`), and `sent_to_approver`. `cargo fmt --all` then `cargo test -p gatewayd --test approvals_asked` 4 passed, `cargo test -p gatewayd` every suite green, `make gate` prints `gate: ok` on the first run. | ? |
| M4b/16-brokerd-refusing-message | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/brokerd/tests/runner.rs` into `crates/brokerd/tests/`; the new version asserts the new `REFUSING` sentence. In `crates/brokerd/src/runner.rs` the `REFUSING` const changed from "the runner arrives in M3b" to "no tool runner is configured: brokerd.toml has no [runner] section"; the doc comment above it unchanged. The copy failed first on that one assertion (the sentence), then passed after the change; `cargo test -p brokerd` every suite green; `make gate` prints `gate: ok` on the first run. `loopd/tests/broker_port.rs` left alone (old sentence is only test data there). | ? |