use crate::authority::Authority;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Decision {
Yes {
grant: String,
line: usize,
used: u64,
max: u64,
thread: String,
},
ThreadClaimDisagreesWithParent,
NoGrant,
GrantIsNotAutoAccept(String),
BudgetSpent { grant: String, used: u64, max: u64 },
}
impl Decision {
pub fn explain(&self) -> String {
match self {
Decision::Yes {
grant, used, max, ..
} => {
format!(
"accepted unattended, {} of {max} in this thread ({grant})",
used + 1
)
}
Decision::NoGrant => "no grant covers it, so it stays pending".to_string(),
Decision::ThreadClaimDisagreesWithParent => {
"it claims a different thread from the message it answers, so it stays pending"
.to_string()
}
Decision::GrantIsNotAutoAccept(g) => {
format!("a grant covers it but does not say auto-accept ({g}), so it stays pending")
}
Decision::BudgetSpent { grant, used, max } => format!(
"this thread has used {used} of {max} auto-accepts ({grant}), so it stays pending"
),
}
}
}
pub fn consider(
home: &std::path::Path,
contacts: &crate::trust::Contacts,
authority: &Authority,
l: &crate::bundle::Loaded,
) -> Decision {
decide(
contacts,
authority,
l,
crate::authority::answers_something_i_sent(home, &l.manifest),
&|t| crate::journal::auto_accepts_in_thread(t),
)
}
pub fn decide(
contacts: &crate::trust::Contacts,
authority: &Authority,
l: &crate::bundle::Loaded,
parent: Option<crate::authority::Parent>,
used: &dyn Fn(&str) -> u64,
) -> Decision {
let m = &l.manifest;
if !l.tampered.is_empty() {
return Decision::NoGrant;
}
let Ok(g) = authority.lookup(contacts, l.trust, &m.from.key, m.msg_type, parent.is_some())
else {
return Decision::NoGrant;
};
let rendered = g.to_string();
if !g.auto_accept {
return Decision::GrantIsNotAutoAccept(rendered);
}
let Some(max) = g.max_exchanges else {
return Decision::GrantIsNotAutoAccept(rendered);
};
let Some(parent) = parent else {
return Decision::NoGrant;
};
if let Some(claimed) = m.thread.as_deref()
&& claimed != parent.thread
{
return Decision::ThreadClaimDisagreesWithParent;
}
let used = used(&parent.thread);
if used >= max {
return Decision::BudgetSpent {
grant: rendered,
used,
max,
};
}
Decision::Yes {
grant: rendered,
line: g.line,
used,
max,
thread: parent.thread,
}
}
pub fn perform(
home: &std::path::Path,
id: &str,
d: &Decision,
) -> std::io::Result<std::path::PathBuf> {
let Decision::Yes {
grant,
line,
used,
max,
thread,
} = d
else {
return Err(std::io::Error::other("not an acceptance"));
};
let from = crate::routes::pending_dir(home).join(id);
let to_dir = crate::routes::accepted_dir(home);
std::fs::create_dir_all(&to_dir)?;
let dest = to_dir.join(id);
crate::journal::record_with_auto(
crate::journal::Event::Accepted,
&from,
Some(crate::journal::Auto {
grant: grant.clone(),
line: *line,
used: *used,
max: *max,
thread: thread.clone(),
}),
)?;
crate::routes::restage(&from, &dest)?;
Ok(dest)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::authority::{Grant, parse};
fn grant(text: &str) -> Grant {
parse(text)
.expect("fixture parses")
.pop()
.expect("one grant")
}
#[test]
fn auto_accept_cannot_be_written_without_a_bound() {
let e = parse("allow codex question auto-accept\n").expect_err("needs a bound");
assert!(e.contains("max-exchanges"), "{e}");
}
#[test]
fn auto_accept_cannot_be_written_against_any() {
let e = parse("allow codex any auto-accept max-exchanges 4\n").expect_err("any is refused");
assert!(e.contains("name the one kind"), "{e}");
}
#[test]
fn a_zero_budget_is_refused_rather_than_read_as_never() {
assert!(parse("allow codex question auto-accept max-exchanges 0\n").is_err());
}
#[test]
fn auto_accept_implies_in_reply_only_even_when_unwritten() {
let g = grant("allow codex question auto-accept max-exchanges 4\n");
assert!(g.in_reply_only, "auto-accept did not imply in-reply-only");
assert!(g.auto_accept);
assert_eq!(g.max_exchanges, Some(4));
}
#[test]
fn a_rendered_grant_shows_what_is_in_force_not_what_was_typed() {
let g = grant("allow codex question auto-accept max-exchanges 2\n");
assert_eq!(
g.to_string(),
"allow codex question in-reply-only auto-accept max-exchanges 2"
);
}
#[test]
fn a_bound_without_auto_accept_is_accepted_and_grants_no_acceptance() {
let g = grant("allow codex question max-exchanges 9\n");
assert!(!g.auto_accept);
assert!(!g.in_reply_only);
}
#[test]
fn every_refusal_explains_itself_without_quoting_a_sender() {
for d in [
Decision::NoGrant,
Decision::ThreadClaimDisagreesWithParent,
Decision::GrantIsNotAutoAccept("allow codex question".into()),
Decision::BudgetSpent {
grant: "allow codex question".into(),
used: 4,
max: 4,
},
] {
let s = d.explain();
assert!(s.contains("stays pending"), "{s}");
}
let yes = Decision::Yes {
grant: "allow codex question".into(),
line: 1,
used: 0,
max: 4,
thread: "parent".into(),
};
assert!(yes.explain().contains("1 of 4"), "{}", yes.explain());
}
fn loaded(claimed_thread: Option<&str>) -> crate::bundle::Loaded {
use crate::manifest::*;
let p = Peer {
handle: "sndr".into(),
key: "SNDRKEY".into(),
};
crate::bundle::Loaded {
manifest: Manifest {
v: 1,
id: "0000000000000000".into(),
created: "2026-10-17T00:00:00Z".into(),
subject: "s".into(),
from: p.clone(),
to: vec!["recv".into()],
agent: None,
chain: vec![Hop {
by: p,
at: "2026-10-17T00:00:00Z".into(),
action: HopAction::Created,
}],
in_reply_to: Some("1111111111111111".into()),
thread: claimed_thread.map(|t| t.to_string()),
msg_type: MsgType::Answer,
expires: None,
context: None,
items: vec![],
},
trust: crate::trust::Trust::Known,
dir: std::path::PathBuf::from("/nonexistent"),
tampered: vec![],
unwitnessed: false,
freshness: Freshness::Undeclared,
context_match: ContextMatch::Undeclared,
}
}
fn setup() -> (crate::trust::Contacts, Authority) {
let mut c = crate::trust::Contacts::default();
c.keys.insert("SNDRKEY".into(), "sndr".into());
let a = Authority {
grants: parse("allow sndr answer auto-accept max-exchanges 1\n").unwrap(),
present: true,
error: None,
};
(c, a)
}
#[test]
fn the_budget_is_keyed_on_the_parent_the_receiver_sent_not_the_senders_claim() {
let (c, a) = setup();
let parent = crate::authority::Parent {
id: "1111111111111111".into(),
thread: "PARENT".into(),
};
let used = |t: &str| if t == "PARENT" { 1 } else { 0 };
let d = decide(&c, &a, &loaded(Some("PARENT")), Some(parent.clone()), &used);
assert!(
matches!(
d,
Decision::BudgetSpent {
used: 1,
max: 1,
..
}
),
"a spent budget was not spent: {d:?}"
);
let d = decide(&c, &a, &loaded(Some("MINTED")), Some(parent.clone()), &used);
assert_eq!(
d,
Decision::ThreadClaimDisagreesWithParent,
"a minted thread claim was not refused"
);
let d = decide(&c, &a, &loaded(None), Some(parent), &used);
assert!(
matches!(d, Decision::BudgetSpent { .. }),
"with no thread claimed, the budget was not keyed on the parent: {d:?}"
);
}
#[test]
fn a_reply_to_nothing_this_machine_sent_reaches_no_auto_accept_grant() {
let (c, a) = setup();
let d = decide(&c, &a, &loaded(Some("x")), None, &|_| 0);
assert_eq!(d, Decision::NoGrant);
}
#[test]
fn perform_refuses_a_decision_that_is_not_an_acceptance() {
let d = Decision::NoGrant;
assert!(perform(std::path::Path::new("/nonexistent"), "deadbeefdeadbeef", &d).is_err());
}
}