use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::core::{Attestation, CaseId, Digest, EffectKey, RunId, Signer, Verifier, canon};
pub const NS: &str = "io.github.hupe1980.agentplane/";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Provenance {
pub run: RunId,
#[serde(skip_serializing_if = "Option::is_none")]
pub case: Option<CaseId>,
pub effect: EffectKey,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dispatch: Option<EffectKey>,
pub agent: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub attestation: Option<Attestation>,
}
impl Provenance {
#[must_use]
pub fn new(run: RunId, effect: EffectKey, agent: impl Into<String>) -> Self {
Self {
run,
case: None,
effect,
dispatch: None,
agent: agent.into(),
attestation: None,
}
}
#[must_use]
pub const fn dispatching(mut self, dispatch: EffectKey) -> Self {
self.dispatch = Some(dispatch);
self
}
#[must_use]
pub fn dedupe_key(&self) -> EffectKey {
self.dispatch.unwrap_or(self.effect)
}
#[must_use]
pub const fn in_case(mut self, case: Option<CaseId>) -> Self {
self.case = case;
self
}
#[must_use]
pub fn payload(&self, target: &str, arguments: &Value) -> Digest {
let claim = json!({
"run": self.run.to_string(),
"case": self.case.map(|c| c.to_string()),
"effect": self.effect.to_string(),
"agent": self.agent,
"target": target,
"arguments": Digest::of(canon::value_bytes(arguments).as_slice()).to_string(),
});
Digest::of(canon::value_bytes(&claim).as_slice())
}
#[must_use]
fn signing_input(&self, target: &str, arguments: &Value) -> Digest {
crate::core::signing_hash(
crate::core::DOMAIN_PROVENANCE,
&self.payload(target, arguments),
)
}
#[must_use]
pub fn seal(mut self, signer: &dyn Signer, target: &str, arguments: &Value) -> Self {
self.attestation = Some(signer.attest(&self.signing_input(target, arguments)));
self
}
#[must_use]
pub fn verify(&self, verifier: &dyn Verifier, target: &str, arguments: &Value) -> bool {
let Some(a) = &self.attestation else {
return false;
};
verifier.verify(
&a.key_id,
&self.signing_input(target, arguments),
&a.signature,
)
}
#[must_use]
pub fn to_meta(&self) -> serde_json::Map<String, Value> {
let mut m = serde_json::Map::new();
m.insert(format!("{NS}run_id"), json!(self.run.to_string()));
if let Some(case) = self.case {
m.insert(format!("{NS}case_id"), json!(case.to_string()));
}
m.insert(format!("{NS}effect_key"), json!(self.effect.to_string()));
m.insert(format!("{NS}agent"), json!(self.agent));
if let Some(a) = &self.attestation {
m.insert(
format!("{NS}attestation"),
serde_json::to_value(a).unwrap_or(Value::Null),
);
}
m
}
#[must_use]
pub fn from_meta(meta: &serde_json::Map<String, Value>) -> Option<Self> {
let s = |k: &str| meta.get(&format!("{NS}{k}"))?.as_str().map(str::to_owned);
Some(Self {
run: RunId::parse(&s("run_id")?).ok()?,
dispatch: None,
case: match s("case_id") {
Some(c) => Some(CaseId::parse(&c).ok()?),
None => None,
},
effect: {
let raw = s("effect_key")?;
EffectKey::from_hex(raw.strip_prefix("ek:").unwrap_or(&raw)).ok()?
},
agent: s("agent")?,
attestation: meta
.get(&format!("{NS}attestation"))
.and_then(|v| serde_json::from_value(v.clone()).ok()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::{Phase, StepId};
fn key(n: u32) -> EffectKey {
EffectKey::derive(StepId(0), Phase::Forward, n, 1, "tool.call", b"{}")
}
#[derive(Debug)]
struct Stub;
impl Signer for Stub {
fn key_id(&self) -> crate::core::KeyId {
"spiffe://example.org/plane/a".to_owned()
}
fn sign(&self, hash: &Digest) -> Vec<u8> {
hash.to_hex().into_bytes()
}
}
impl Verifier for Stub {
fn verify(&self, key_id: &str, hash: &Digest, signature: &[u8]) -> bool {
key_id == self.key_id() && signature == self.sign(hash)
}
}
fn block() -> Provenance {
Provenance::new(RunId::generate(), key(0), "auditor@2.0.0")
}
#[test]
fn a_sealed_block_verifies_for_the_call_it_was_sealed_for() {
let args = json!({ "target_id": "ID-88219-A" });
let p = block().seal(&Stub, "data.fetch", &args);
assert!(p.verify(&Stub, "data.fetch", &args));
}
#[test]
fn an_attestation_cannot_be_lifted_onto_another_tool() {
let args = json!({ "amount": 1 });
let p = block().seal(&Stub, "reports.read", &args);
assert!(
!p.verify(&Stub, "billing.transfer", &args),
"a block sealed for one tool verified on another — provenance that \
travels is provenance that proves nothing about the call carrying it"
);
}
#[test]
fn an_attestation_cannot_be_lifted_onto_other_arguments() {
let p = block().seal(&Stub, "billing.transfer", &json!({ "amount": 1 }));
assert!(
!p.verify(&Stub, "billing.transfer", &json!({ "amount": 1_000_000 })),
"the amount changed and the attestation still verified"
);
}
#[test]
fn argument_key_order_does_not_change_the_payload() {
let p = block();
let a = json!({ "a": 1, "b": 2 });
let b = json!({ "b": 2, "a": 1 });
assert_eq!(p.payload("t", &a), p.payload("t", &b));
}
#[test]
fn a_different_run_is_a_different_payload() {
let args = json!({});
let one = block().seal(&Stub, "t", &args);
let two =
Provenance::new(RunId::generate(), key(0), "auditor@2.0.0").seal(&Stub, "t", &args);
assert_ne!(one.attestation, two.attestation);
}
#[test]
fn an_unsigned_block_never_verifies() {
assert!(
!block().verify(&Stub, "t", &json!({})),
"an unsigned block must not pass — a plane with no identity cannot \
attest, and treating absence as assent is how a compromised \
intermediary gets believed"
);
}
#[test]
fn a_tampered_field_is_caught() {
let args = json!({});
let mut p = block().seal(&Stub, "t", &args);
p.agent = "someone-else@9.9.9".to_owned();
assert!(!p.verify(&Stub, "t", &args));
}
#[test]
fn the_wire_form_round_trips() {
let args = json!({ "x": 1 });
let p = block()
.in_case(Some(crate::core::CaseId::generate()))
.seal(&Stub, "t", &args);
let meta = p.to_meta();
let back = Provenance::from_meta(&meta).expect("round trip");
assert_eq!(back, p);
assert!(back.verify(&Stub, "t", &args), "and it still verifies");
}
#[test]
fn every_wire_key_is_namespaced() {
let meta = block().seal(&Stub, "t", &json!({})).to_meta();
assert!(meta.keys().all(|k| k.starts_with(NS)), "{meta:?}");
assert!(meta.contains_key("io.github.hupe1980.agentplane/run_id"));
}
#[test]
fn a_stripped_attestation_does_not_verify() {
let args = json!({});
let p = block().seal(&Stub, "t", &args);
let mut meta = p.to_meta();
meta.remove("io.github.hupe1980.agentplane/attestation");
let back = Provenance::from_meta(&meta).expect("still parses");
assert!(back.attestation.is_none());
assert!(!back.verify(&Stub, "t", &args));
}
}