use crate::ids::{
AggregateId, ByteCount, CommandId, CorrelationId, EventId, IdempotencyKey, ProjectId, Seq,
Timestamp,
};
pub const ENVELOPE_SCHEMA_VERSION: u32 = 1;
pub const INLINE_PAYLOAD_MAX_BYTES: usize = 64 * 1024;
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(untagged)]
pub enum JsonValue {
Null(()),
Bool(bool),
Number(f64),
String(String),
Array(Vec<JsonValue>),
Object(std::collections::BTreeMap<String, JsonValue>),
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(deny_unknown_fields)]
pub struct Actor {
pub kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(deny_unknown_fields)]
pub struct Origin {
pub system: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub r#ref: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(deny_unknown_fields)]
pub struct PayloadRef {
pub digest: String,
pub media_type: String,
pub byte_size: ByteCount,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub retention_class: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub evidence_pin: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(deny_unknown_fields)]
pub struct EventEnvelope {
pub event_id: EventId,
pub project_id: ProjectId,
pub aggregate_type: String,
pub aggregate_id: AggregateId,
pub aggregate_version: u32,
pub event_type: String,
pub schema_version: u32,
pub global_sequence: Seq,
pub occurred_at: Timestamp,
pub appended_at: Timestamp,
pub actor: Actor,
pub origin: Origin,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub causation_id: Option<EventId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub correlation_id: Option<CorrelationId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub idempotency_key: Option<IdempotencyKey>,
#[specta(type = JsonValue)]
pub payload: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub payload_ref: Option<PayloadRef>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(deny_unknown_fields)]
pub struct CommandEnvelope {
pub command_id: CommandId,
pub project_id: ProjectId,
pub command_type: String,
pub schema_version: u32,
pub issued_at: Timestamp,
pub actor: Actor,
pub origin: Origin,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub target_aggregate_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub target_aggregate_id: Option<AggregateId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub expected_version: Option<u32>,
pub idempotency_key: IdempotencyKey,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub causation_id: Option<EventId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub correlation_id: Option<CorrelationId>,
#[specta(type = JsonValue)]
pub payload: serde_json::Value,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UnknownSchemaVersion {
pub found: u32,
pub supported: u32,
}
impl std::fmt::Display for UnknownSchemaVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"unknown schema_version {} (supported: {}, no upcaster registered)",
self.found, self.supported
)
}
}
impl std::error::Error for UnknownSchemaVersion {}
pub fn accept_schema_version(
found: u32,
supported: u32,
upcast_from: &[u32],
) -> Result<(), UnknownSchemaVersion> {
if found == supported || upcast_from.contains(&found) {
Ok(())
} else {
Err(UnknownSchemaVersion { found, supported })
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture() -> EventEnvelope {
EventEnvelope {
event_id: EventId::new("evt-1"),
project_id: ProjectId::new("proj-1"),
aggregate_type: "task".into(),
aggregate_id: AggregateId::new("task-1"),
aggregate_version: 1,
event_type: "task_state_changed".into(),
schema_version: ENVELOPE_SCHEMA_VERSION,
global_sequence: Seq::new(42),
occurred_at: Timestamp::new("2026-07-27T00:00:00Z"),
appended_at: Timestamp::new("2026-07-27T00:00:01Z"),
actor: Actor {
kind: "kernel".into(),
id: None,
},
origin: Origin {
system: "kernel".into(),
r#ref: None,
},
causation_id: None,
correlation_id: None,
idempotency_key: None,
payload: serde_json::json!({ "to": "working" }),
payload_ref: None,
}
}
#[test]
fn envelope_round_trips_and_omits_absent_optionals() {
let env = fixture();
let json = serde_json::to_string(&env).expect("serialize");
assert!(!json.contains("causation_id"));
assert!(!json.contains("payload_ref"));
assert!(json.contains("\"system\":\"kernel\""));
assert!(json.contains("\"global_sequence\":\"42\""));
let back: EventEnvelope = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, env);
}
#[test]
fn envelope_rejects_unknown_fields() {
let mut value = serde_json::to_value(fixture()).expect("to_value");
value["surprise"] = serde_json::json!(true);
assert!(serde_json::from_value::<EventEnvelope>(value).is_err());
}
#[test]
fn schema_version_gate_is_typed() {
assert!(accept_schema_version(1, 1, &[]).is_ok());
assert!(accept_schema_version(0, 1, &[0]).is_ok());
assert_eq!(
accept_schema_version(9, 1, &[0]),
Err(UnknownSchemaVersion {
found: 9,
supported: 1
})
);
}
}