use crate::aprp::PaymentRequest;
use crate::receipt::{Decision, PolicyReceipt};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ExecutionError {
#[error("policy receipt rejected: decision={0:?}")]
NotApproved(Decision),
#[error("sponsor backend offline: {0}")]
BackendOffline(String),
#[error("integration: {0}")]
Integration(String),
#[error("protocol: {0}")]
ProtocolError(String),
}
#[derive(Debug, Clone)]
pub struct ExecutionReceipt {
pub sponsor: &'static str,
pub execution_ref: String,
pub mock: bool,
pub note: String,
pub evidence: Option<serde_json::Value>,
}
pub trait GuardedExecutor {
fn sponsor_id(&self) -> &'static str;
fn execute(
&self,
request: &PaymentRequest,
receipt: &PolicyReceipt,
) -> Result<ExecutionReceipt, ExecutionError>;
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Sbo3lEnvelope {
pub sbo3l_request_hash: String,
pub sbo3l_policy_hash: String,
pub sbo3l_receipt_signature: String,
pub sbo3l_audit_event_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sbo3l_passport_capsule_hash: Option<String>,
}
impl Sbo3lEnvelope {
pub fn from_receipt(receipt: &PolicyReceipt, audit_event_id: &str) -> Self {
Self {
sbo3l_request_hash: receipt.request_hash.clone(),
sbo3l_policy_hash: receipt.policy_hash.clone(),
sbo3l_receipt_signature: receipt.signature.signature_hex.clone(),
sbo3l_audit_event_id: audit_event_id.to_string(),
sbo3l_passport_capsule_hash: None,
}
}
pub fn with_passport_capsule(mut self, capsule_hash: String) -> Self {
self.sbo3l_passport_capsule_hash = Some(capsule_hash);
self
}
pub fn to_json_payload(&self) -> String {
serde_json::to_string(self)
.expect("Sbo3lEnvelope's #[derive(Serialize)] is infallible for owned fields")
}
}
#[cfg(test)]
mod envelope_tests {
use super::*;
use crate::receipt::{EmbeddedSignature, ReceiptType, SignatureAlgorithm};
fn fixture_receipt() -> PolicyReceipt {
PolicyReceipt {
receipt_type: ReceiptType::PolicyReceiptV1,
version: 1,
agent_id: "research-agent-01".to_string(),
decision: Decision::Allow,
deny_code: None,
request_hash: "a".repeat(64),
policy_hash: "b".repeat(64),
policy_version: Some(1),
audit_event_id: "evt-01HTAWX5K3R8YV9NQB7C6P2DGS".to_string(),
execution_ref: None,
issued_at: chrono::Utc::now(),
expires_at: None,
signature: EmbeddedSignature {
algorithm: SignatureAlgorithm::Ed25519,
key_id: "decision-signer-v1".to_string(),
signature_hex: "f".repeat(128),
},
}
}
#[test]
fn envelope_constructed_from_real_receipt() {
let r = fixture_receipt();
let env = Sbo3lEnvelope::from_receipt(&r, &r.audit_event_id);
assert_eq!(env.sbo3l_request_hash, r.request_hash);
assert_eq!(env.sbo3l_policy_hash, r.policy_hash);
assert_eq!(env.sbo3l_receipt_signature, r.signature.signature_hex);
assert_eq!(env.sbo3l_audit_event_id, r.audit_event_id);
assert_eq!(env.sbo3l_passport_capsule_hash, None);
}
#[test]
fn envelope_serialises_with_documented_field_order() {
let r = fixture_receipt();
let env = Sbo3lEnvelope::from_receipt(&r, &r.audit_event_id)
.with_passport_capsule("c".repeat(64));
let s = serde_json::to_string(&env).expect("serialise");
let r_idx = s.find("sbo3l_request_hash").expect("request_hash key");
let p_idx = s.find("sbo3l_policy_hash").expect("policy_hash key");
let sig_idx = s.find("sbo3l_receipt_signature").expect("signature key");
let ev_idx = s.find("sbo3l_audit_event_id").expect("audit_event_id key");
let cap_idx = s
.find("sbo3l_passport_capsule_hash")
.expect("capsule_hash key");
assert!(
r_idx < p_idx && p_idx < sig_idx && sig_idx < ev_idx && ev_idx < cap_idx,
"field order violated; got serialised body: {s}"
);
}
#[test]
fn envelope_audit_event_id_matches_crockford_pattern() {
let r = fixture_receipt();
let env = Sbo3lEnvelope::from_receipt(&r, &r.audit_event_id);
let id = &env.sbo3l_audit_event_id;
assert!(id.starts_with("evt-"), "got: {id}");
let body = &id["evt-".len()..];
assert_eq!(body.len(), 26, "ULID body must be 26 chars; got: {body}");
let mut cs = body.chars();
let first = cs.next().expect("non-empty");
assert!(
('0'..='7').contains(&first),
"first char must be 0-7 (ULID timestamp upper bits); got: {first}"
);
const CROCKFORD_TAIL: &str = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
for c in cs {
assert!(
CROCKFORD_TAIL.contains(c),
"tail char {c:?} not in Crockford alphabet (no I/L/O/U)"
);
}
}
#[test]
fn envelope_capsule_hash_omitted_when_none_via_skip_serializing() {
let r = fixture_receipt();
let env = Sbo3lEnvelope::from_receipt(&r, &r.audit_event_id);
let payload = env.to_json_payload();
let v: serde_json::Value = serde_json::from_str(&payload).expect("parse payload");
let obj = v.as_object().expect("object");
assert!(
!obj.contains_key("sbo3l_passport_capsule_hash"),
"skip_serializing_if didn't omit the absent capsule hash; obj: {obj:?}"
);
assert_eq!(obj.len(), 4, "expected 4 fields, got: {obj:?}");
}
#[test]
fn envelope_capsule_hash_present_when_with_passport_capsule_called() {
let r = fixture_receipt();
let capsule_hash = "d".repeat(64);
let env = Sbo3lEnvelope::from_receipt(&r, &r.audit_event_id)
.with_passport_capsule(capsule_hash.clone());
assert_eq!(env.sbo3l_passport_capsule_hash, Some(capsule_hash.clone()));
let payload = env.to_json_payload();
let v: serde_json::Value = serde_json::from_str(&payload).expect("parse payload");
assert_eq!(
v.get("sbo3l_passport_capsule_hash")
.and_then(|v| v.as_str()),
Some(capsule_hash.as_str())
);
}
#[test]
fn envelope_to_json_payload_round_trips_via_serde() {
let r = fixture_receipt();
let original = Sbo3lEnvelope::from_receipt(&r, &r.audit_event_id)
.with_passport_capsule("9".repeat(64));
let payload = original.to_json_payload();
let round_tripped: Sbo3lEnvelope =
serde_json::from_str(&payload).expect("round-trip deserialise");
assert_eq!(round_tripped, original);
}
#[test]
fn to_json_payload_preserves_documented_field_order() {
let r = fixture_receipt();
let env = Sbo3lEnvelope::from_receipt(&r, &r.audit_event_id)
.with_passport_capsule("c".repeat(64));
let payload = env.to_json_payload();
let order = [
"sbo3l_request_hash",
"sbo3l_policy_hash",
"sbo3l_receipt_signature",
"sbo3l_audit_event_id",
"sbo3l_passport_capsule_hash",
];
let mut cursor = 0usize;
for key in order {
let needle = format!(r#""{key}":"#);
let pos = payload
.find(&needle)
.unwrap_or_else(|| panic!("missing key {key} in payload: {payload}"));
assert!(
pos >= cursor,
"field {key} appears out of documented order in payload: {payload}"
);
cursor = pos;
}
}
}