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