khive_changeset/
envelope.rs1use khive_types::Timestamp;
4use serde::{Deserialize, Serialize};
5
6pub const CURRENT_SCHEMA_VERSION: u32 = 1;
8
9#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
12#[serde(deny_unknown_fields)]
13pub struct Envelope {
14 pub schema_version: u32,
16 pub producer: String,
18 pub producer_model_family: String,
20 pub staged_at: Timestamp,
22 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub batch_id: Option<String>,
30}
31
32impl Envelope {
33 pub fn new(
35 producer: impl Into<String>,
36 producer_model_family: impl Into<String>,
37 staged_at: Timestamp,
38 ) -> Self {
39 Self {
40 schema_version: CURRENT_SCHEMA_VERSION,
41 producer: producer.into(),
42 producer_model_family: producer_model_family.into(),
43 staged_at,
44 batch_id: None,
45 }
46 }
47
48 pub fn with_batch_id(mut self, batch_id: impl Into<String>) -> Self {
50 self.batch_id = Some(batch_id.into());
51 self
52 }
53}
54
55#[cfg(test)]
56mod tests {
57 use super::*;
58
59 #[test]
60 fn new_stamps_current_schema_version() {
61 let env = Envelope::new("agent:test", "family:sonnet", Timestamp::from_secs(1));
62 assert_eq!(env.schema_version, CURRENT_SCHEMA_VERSION);
63 assert_eq!(env.producer, "agent:test");
64 assert_eq!(env.producer_model_family, "family:sonnet");
65 }
66
67 #[test]
68 fn rejects_unknown_field() {
69 let json = serde_json::json!({
70 "schema_version": 1,
71 "producer": "agent:test",
72 "producer_model_family": "family:sonnet",
73 "staged_at": 1_000_000_u64,
74 "unexpected": "surprise"
75 });
76 let result: Result<Envelope, _> = serde_json::from_value(json);
77 assert!(result.is_err());
78 }
79
80 #[test]
81 fn batch_id_absent_by_default_and_not_serialized() {
82 let env = Envelope::new("agent:test", "family:sonnet", Timestamp::from_secs(1));
83 assert_eq!(env.batch_id, None);
84 let json = serde_json::to_string(&env).unwrap();
85 assert!(
86 !json.contains("batch_id"),
87 "absent batch_id must not appear on the wire at all (not even as null): {json}"
88 );
89 }
90
91 #[test]
92 fn batch_id_round_trips_when_present() {
93 let env = Envelope::new("agent:test", "family:sonnet", Timestamp::from_secs(1))
94 .with_batch_id("batch-123");
95 let json = serde_json::to_string(&env).unwrap();
96 let decoded: Envelope = serde_json::from_str(&json).unwrap();
97 assert_eq!(decoded, env);
98 assert_eq!(decoded.batch_id.as_deref(), Some("batch-123"));
99 }
100
101 #[test]
102 fn batch_id_round_trips_when_absent() {
103 let env = Envelope::new("agent:test", "family:sonnet", Timestamp::from_secs(1));
104 let json = serde_json::to_string(&env).unwrap();
105 let decoded: Envelope = serde_json::from_str(&json).unwrap();
106 assert_eq!(decoded, env);
107 assert_eq!(decoded.batch_id, None);
108 }
109}