use std::sync::{Arc, Mutex};
use agentplane::core::{
Outcome, ProtectedField, Release, ReleaseScope, Sensitivity, Skill, SkillDescriptor,
SkillError, SourceId, Tainted,
};
use agentplane::journal::JournalStore;
use agentplane::runtime::{RunStatus, Runtime, StepCtx};
use agentplane::store::RedbStore;
use agentplane::tools::{ToolCall, ToolCatalog, ToolClient, ToolError, ToolId, ToolSafety};
use serde_json::{Value, json};
fn transfer() -> ToolId {
ToolId::new("ledger", "transfer")
}
#[derive(Debug, Default)]
struct Ledger {
calls: Mutex<Vec<Value>>,
}
#[async_trait::async_trait]
impl ToolClient for Ledger {
async fn call(
&self,
_tool: &ToolId,
arguments: &Value,
_provenance: Option<&agentplane::core::Provenance>,
) -> Result<Value, ToolError> {
self.calls.lock().unwrap().push(arguments.clone());
Ok(json!({ "posted": true }))
}
}
#[derive(Debug)]
struct Transfer {
catalog: ToolCatalog,
ledger: Arc<Ledger>,
}
#[async_trait::async_trait]
impl Skill for Transfer {
fn descriptor(&self) -> SkillDescriptor {
SkillDescriptor::new("transfer").provides("ledger.transfer")
}
async fn invoke(
&self,
cx: &mut StepCtx<'_>,
input: Tainted<Value>,
) -> Result<Outcome, SkillError> {
let scenario = input
.peek()
.get("scenario")
.and_then(Value::as_str)
.unwrap_or("trusted");
let recipient = match scenario {
"trusted" => Tainted::trusted(json!("treasury")),
_ => Tainted::from_source(json!("treasury"), SourceId::new("model.complete")),
};
let arguments = Tainted::object([
("recipient".to_owned(), recipient),
(
"memo".to_owned(),
Tainted::from_source(
json!("model-written description may remain untrusted"),
SourceId::new("model.complete"),
),
),
("amount".to_owned(), Tainted::trusted(json!(1250))),
]);
let arguments = if scenario == "released" {
cx.release(
arguments,
Release::fields(
ReleaseScope::trust(),
["/recipient".to_owned()],
"operator matched the account to settlement SET-42",
"tool://ledger/transfer",
["approval:SET-42".to_owned()],
),
)
.await?
} else {
arguments
};
let call = ToolCall::prepare(
&self.catalog,
Arc::clone(&self.ledger) as Arc<dyn ToolClient>,
transfer(),
arguments.peek().clone(),
)?;
let result = cx.sink(call, &arguments).await?;
Ok(Outcome::done(result))
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let store: Arc<dyn JournalStore> = Arc::new(RedbStore::open_in_memory()?);
let ledger = Arc::new(Ledger::default());
let catalog = ToolCatalog::new().allow(
transfer(),
ToolSafety::default()
.max_sensitivity(Sensitivity::Internal)
.protect(ProtectedField::trusted("/recipient")),
);
let runtime = Runtime::builder(Arc::clone(&store))
.skill(Transfer {
catalog,
ledger: Arc::clone(&ledger),
})
.build();
let trusted = runtime
.run(
"ledger.transfer",
Tainted::trusted(json!({ "scenario": "trusted" })),
)
.await?;
println!(
"1. trusted recipient + untrusted memo → {:?}",
trusted.status
);
assert_eq!(trusted.status, RunStatus::Succeeded);
assert_eq!(ledger.calls.lock().unwrap().len(), 1);
let refused = runtime
.run(
"ledger.transfer",
Tainted::trusted(json!({ "scenario": "untrusted" })),
)
.await?;
println!(
"2. untrusted recipient → {:?}",
refused.status
);
assert!(matches!(refused.status, RunStatus::Failed(_)));
assert_eq!(
ledger.calls.lock().unwrap().len(),
1,
"refused before dispatch"
);
let released = runtime
.run(
"ledger.transfer",
Tainted::trusted(json!({ "scenario": "released" })),
)
.await?;
println!(
"3. field release with evidence → {:?}",
released.status
);
assert_eq!(released.status, RunStatus::Succeeded);
assert_eq!(ledger.calls.lock().unwrap().len(), 2);
for run in [trusted.run_id, refused.run_id, released.run_id] {
store.verify(run).await?;
}
println!("4. all three decision trails verify");
Ok(())
}