Skip to main content

khive_changeset/
envelope.rs

1//! Change-set envelope — producer identity, captured at stage time.
2
3use khive_types::Timestamp;
4use serde::{Deserialize, Serialize};
5
6/// The NDJSON-delta schema version this crate currently emits and accepts.
7pub const CURRENT_SCHEMA_VERSION: u32 = 1;
8
9/// Change-set-wide producer identity and stage-time provenance.
10///
11/// See `crates/khive-changeset/docs/api/envelope.md` for commit-trailer behavior.
12#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
13#[serde(deny_unknown_fields)]
14pub struct Envelope {
15    /// NDJSON-delta format version this envelope was staged under.
16    pub schema_version: u32,
17    /// Opaque producer identity token (interactive agent id, pipeline name, ...).
18    pub producer: String,
19    /// Opaque producer model family token, read by the cross-family review gate.
20    pub producer_model_family: String,
21    /// Wall-clock time the change-set was staged, supplied by the caller.
22    pub staged_at: Timestamp,
23    /// Optional producer batch ID used verbatim for commit provenance; absent
24    /// values let the committer derive a deterministic producer/time fallback.
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub batch_id: Option<String>,
27}
28
29impl Envelope {
30    /// Constructs an envelope at [`CURRENT_SCHEMA_VERSION`] with no batch ID.
31    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    /// Attaches an opaque producer-assigned batch identifier.
46    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}