use chrono::{DateTime, Utc};
use cortex_core::{
canonical::{AttestationPreimage, LineageBinding, SourceIdentity, SCHEMA_VERSION_ATTESTATION},
Event, EventSource,
};
use serde::{Deserialize, Serialize};
pub const GENESIS_PREV_SIGNATURE: [u8; 32] = [0u8; 32];
pub const IDENTITY_ROTATE_PAYLOAD_KIND: &str = "identity.rotate";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RotationPayload {
pub kind: String,
pub envelope: cortex_core::attestor::RotationEnvelope,
}
impl RotationPayload {
#[must_use]
pub fn new(envelope: cortex_core::attestor::RotationEnvelope) -> Self {
Self {
kind: IDENTITY_ROTATE_PAYLOAD_KIND.to_string(),
envelope,
}
}
}
#[must_use]
pub fn hex_lower(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
#[must_use]
pub fn row_preimage(
event: &Event,
prev_signature: &[u8; 32],
ledger_id: &str,
key_id: &str,
signed_at: DateTime<Utc>,
) -> AttestationPreimage {
AttestationPreimage {
schema_version: SCHEMA_VERSION_ATTESTATION,
source: source_identity_for(&event.source),
event_id: event.id.to_string(),
payload_hash: event.payload_hash.clone(),
session_id: event.session_id.clone().unwrap_or_default(),
ledger_id: ledger_id.to_string(),
lineage: LineageBinding::PreviousHash(hex_lower(prev_signature)),
signed_at,
key_id: key_id.to_string(),
}
}
#[must_use]
pub fn source_identity_for(source: &EventSource) -> SourceIdentity {
match source {
EventSource::User => SourceIdentity::User,
EventSource::ChildAgent { model } => SourceIdentity::ChildAgent {
agent_id: String::new(),
parent_session_id: String::new(),
delegation_id: String::new(),
model: model.clone(),
},
EventSource::Tool { name } => SourceIdentity::Tool { name: name.clone() },
EventSource::Runtime => SourceIdentity::Runtime,
EventSource::ExternalOutcome => SourceIdentity::ExternalOutcome,
EventSource::ManualCorrection => SourceIdentity::ManualCorrection,
}
}
#[must_use]
pub fn is_identity_rotate(event: &Event) -> bool {
matches!(event.event_type, cortex_core::EventType::SystemNote)
&& event
.payload
.as_object()
.and_then(|o| o.get("kind"))
.and_then(|v| v.as_str())
== Some(IDENTITY_ROTATE_PAYLOAD_KIND)
}
#[must_use]
pub fn extract_rotation_payload(event: &Event) -> Option<RotationPayload> {
if !is_identity_rotate(event) {
return None;
}
serde_json::from_value(event.payload.clone()).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
use cortex_core::{
attestor::{sign_rotation, Attestor, InMemoryAttestor},
Event, EventId, EventType, SCHEMA_VERSION,
};
fn fresh_attestor(seed: u8) -> InMemoryAttestor {
InMemoryAttestor::from_seed(&[seed; 32])
}
fn fixture_event() -> Event {
Event {
id: EventId::new(),
schema_version: SCHEMA_VERSION,
observed_at: Utc.with_ymd_and_hms(2026, 5, 3, 12, 0, 0).unwrap(),
recorded_at: Utc.with_ymd_and_hms(2026, 5, 3, 12, 0, 1).unwrap(),
source: EventSource::User,
event_type: EventType::UserMessage,
trace_id: None,
session_id: Some("s-001".into()),
domain_tags: vec![],
payload: serde_json::json!({"text": "hi"}),
payload_hash: "deadbeef".into(),
prev_event_hash: None,
event_hash: "feedface".into(),
}
}
#[test]
fn genesis_preimage_uses_zero_sentinel() {
let event = fixture_event();
let signed_at = Utc.with_ymd_and_hms(2026, 5, 3, 12, 0, 2).unwrap();
let p = row_preimage(
&event,
&GENESIS_PREV_SIGNATURE,
"ledger-test",
"fp:abc",
signed_at,
);
match p.lineage {
LineageBinding::PreviousHash(s) => assert_eq!(s, "0".repeat(64)),
other => panic!("expected PreviousHash for genesis sentinel, got {other:?}"),
}
}
#[test]
fn preimage_changes_with_prev_signature() {
let event = fixture_event();
let signed_at = Utc.with_ymd_and_hms(2026, 5, 3, 12, 0, 2).unwrap();
let mut prev_a = [0u8; 32];
prev_a[0] = 0xAA;
let mut prev_b = [0u8; 32];
prev_b[0] = 0xBB;
let pa = row_preimage(&event, &prev_a, "ledger-test", "fp:abc", signed_at);
let pb = row_preimage(&event, &prev_b, "ledger-test", "fp:abc", signed_at);
assert_ne!(pa.lineage, pb.lineage);
}
#[test]
fn rotation_payload_round_trips() {
let old = fresh_attestor(1);
let new = fresh_attestor(2);
let signed_at = Utc.with_ymd_and_hms(2026, 5, 3, 12, 0, 0).unwrap();
let env = sign_rotation(&old.verifying_key(), &new.verifying_key(), signed_at, &old);
let rp = RotationPayload::new(env.clone());
let json = serde_json::to_value(&rp).unwrap();
assert_eq!(json["kind"], "identity.rotate");
let back: RotationPayload = serde_json::from_value(json).unwrap();
assert_eq!(back.envelope, env);
}
#[test]
fn is_identity_rotate_true_only_for_systemnote_with_kind() {
let mut e = fixture_event();
assert!(!is_identity_rotate(&e));
e.event_type = EventType::SystemNote;
e.payload = serde_json::json!({"kind": "identity.rotate", "envelope": {}});
assert!(is_identity_rotate(&e));
e.payload = serde_json::json!({"kind": "other"});
assert!(!is_identity_rotate(&e));
}
}