use std::sync::Arc;
use agentplane::authority::{AuthorityError, AuthorityId, AuthorityStore, StandingAuthority};
use agentplane::core::{Outcome, Skill, SkillDescriptor, SkillError, Spend, Tainted};
use agentplane::journal::JournalStore;
use agentplane::runtime::{Runtime, StepCtx};
use agentplane::store::RedbStore;
use serde_json::{Value, json};
#[derive(Debug)]
struct Purchase;
#[async_trait::async_trait]
impl Skill for Purchase {
fn descriptor(&self) -> SkillDescriptor {
SkillDescriptor::new("purchase").provides("procurement.purchase")
}
async fn invoke(
&self,
cx: &mut StepCtx<'_>,
input: Tainted<Value>,
) -> Result<Outcome, SkillError> {
let cents = input.peek()["cents"].as_u64().unwrap_or(0);
match cx
.draw(&AuthorityId::new("mandate-42"), Spend::money(cents))
.await
{
Ok(drawn) => Ok(Outcome::done(Tainted::trusted(json!({
"charged": cents,
"remaining": drawn.remaining.minor_units,
"draw": drawn.draws,
})))),
Err(refused) => Ok(Outcome::done(Tainted::trusted(json!({
"refused": refused.to_string(),
})))),
}
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let store = Arc::new(RedbStore::open_in_memory()?);
store
.issue(&StandingAuthority::new(
"mandate-42",
"approval:SET-42",
Spend::money(50_000),
))
.await?;
let journal: Arc<dyn JournalStore> = store.clone();
let authorities: Arc<dyn AuthorityStore> = store.clone();
let runtime = Runtime::builder(journal)
.authorities(authorities)
.skill(Purchase)
.build();
let first = runtime
.run(
"procurement.purchase",
Tainted::trusted(json!({"cents": 30_000})),
)
.await?;
assert_eq!(
first.output.as_ref().unwrap().peek()["remaining"],
json!(20_000)
);
println!("1. issued → mandate-42: 50000 minor units against 'approval:SET-42'");
println!(" first run drew → 30000, remaining 20000");
let second = runtime
.run(
"procurement.purchase",
Tainted::trusted(json!({"cents": 15_000})),
)
.await?;
assert_eq!(
second.output.as_ref().unwrap().peek()["remaining"],
json!(5_000)
);
println!(" second run drew → 15000, remaining 5000 — one envelope across separate runs,");
println!(" which is the thing a per-run budget cannot hold");
let over = runtime
.run(
"procurement.purchase",
Tainted::trusted(json!({"cents": 10_000})),
)
.await?;
let message = over.output.as_ref().unwrap().peek()["refused"]
.as_str()
.unwrap();
assert!(message.contains("does not replenish"), "got: {message}");
println!("\n2. 10000 refused → {message}");
assert_eq!(
store
.state(&AuthorityId::new("mandate-42"))
.await?
.expect("issued")
.remaining(),
Spend::money(5_000),
"a refusal must not consume"
);
println!(
" remaining → still 5000: a refused draw consumes nothing, so probing cannot drain"
);
revocation(&runtime, &store).await?;
Ok(())
}
async fn revocation(
runtime: &Runtime,
store: &Arc<RedbStore>,
) -> Result<(), Box<dyn std::error::Error>> {
store
.revoke(
&AuthorityId::new("mandate-42"),
"the customer cancelled",
agentplane::core::Timestamp::UNIX_EPOCH,
)
.await?;
let after = runtime
.run(
"procurement.purchase",
Tainted::trusted(json!({"cents": 1_000})),
)
.await?;
let message = after.output.as_ref().unwrap().peek()["refused"]
.as_str()
.unwrap();
assert!(message.contains("was revoked"), "got: {message}");
println!("\n3. after revocation → {message}");
println!(" a different answer from exhaustion: asking for less is pointless now");
let state = store
.state(&AuthorityId::new("mandate-42"))
.await?
.expect("revoked, not deleted");
assert_eq!(state.authority.basis, "approval:SET-42");
assert_eq!(state.draws, 2);
let err = store
.issue(&StandingAuthority::new(
"mandate-42",
"approval:SET-42",
Spend::money(90_000),
))
.await
.expect_err("an id cannot be redefined under the draws already taken");
assert!(matches!(err, AuthorityError::AlreadyIssued(_)));
println!(
"\n4. the terms survive → {} drawn over {} draws against '{}'; revoked: \"{}\"",
state.drawn.minor_units,
state.draws,
state.authority.basis,
state.revoked.expect("revoked").reason,
);
println!(" and the id cannot be reissued under the draws already taken");
Ok(())
}