From 861ff7d06a16e640e1c86b26e07f6c743be8ac6d Mon Sep 17 00:00:00 2001 From: "K. Isom" Date: Thu, 24 Sep 2026 15:50:28 -0700 Subject: [PATCH] brokerd: policy and ledger as modules, before the memory tools Implemented-By: OpenCode session (model recorded in docs/implementer-log.md) --- crates/brokerd/src/ledger/answer.rs | 204 ++++++++++++++++++ .../brokerd/src/{ledger.rs => ledger/mod.rs} | 199 +---------------- crates/brokerd/src/policy/matching.rs | 187 ++++++++++++++++ .../brokerd/src/{policy.rs => policy/mod.rs} | 188 +--------------- docs/implementer-log.md | 1 + 5 files changed, 401 insertions(+), 378 deletions(-) create mode 100644 crates/brokerd/src/ledger/answer.rs rename crates/brokerd/src/{ledger.rs => ledger/mod.rs} (58%) create mode 100644 crates/brokerd/src/policy/matching.rs rename crates/brokerd/src/{policy.rs => policy/mod.rs} (59%) diff --git a/crates/brokerd/src/ledger/answer.rs b/crates/brokerd/src/ledger/answer.rs new file mode 100644 index 0000000..3a45949 --- /dev/null +++ b/crates/brokerd/src/ledger/answer.rs @@ -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() + } + } + } +} diff --git a/crates/brokerd/src/ledger.rs b/crates/brokerd/src/ledger/mod.rs similarity index 58% rename from crates/brokerd/src/ledger.rs rename to crates/brokerd/src/ledger/mod.rs index cc87658..1aa58e1 100644 --- a/crates/brokerd/src/ledger.rs +++ b/crates/brokerd/src/ledger/mod.rs @@ -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() - } - } - } } diff --git a/crates/brokerd/src/policy/matching.rs b/crates/brokerd/src/policy/matching.rs new file mode 100644 index 0000000..93f977f --- /dev/null +++ b/crates/brokerd/src/policy/matching.rs @@ -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, + 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, + pub(super) paths: Vec, + pub(super) hosts: Vec, + pub(super) expires: Option, + 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 = 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> { + 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> { + let mut best: Option = 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 { + 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 } +} diff --git a/crates/brokerd/src/policy.rs b/crates/brokerd/src/policy/mod.rs similarity index 59% rename from crates/brokerd/src/policy.rs rename to crates/brokerd/src/policy/mod.rs index dd433d3..2eecb59 100644 --- a/crates/brokerd/src/policy.rs +++ b/crates/brokerd/src/policy/mod.rs @@ -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, - expired: bool, - tainted: bool, -} - -#[derive(Clone)] -struct Candidate { - id: String, - grant_sha256: Hash32, - mode: Mode, - matched_path: Option, - paths: Vec, - hosts: Vec, - expires: Option, - 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 = 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> { - 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> { - let mut best: Option = 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 { - 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 } -} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 7b0e001..24669ce 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 | |---|---|---|---|---|---|---|---| +| 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` and `askers: HashMap` 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). | ? |