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-level metadata block. Carries producer identity and model
10/// family; individual ops never reference it and stay producer-agnostic.
11#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
12#[serde(deny_unknown_fields)]
13pub struct Envelope {
14    /// NDJSON-delta format version this envelope was staged under.
15    pub schema_version: u32,
16    /// Opaque producer identity token (interactive agent id, pipeline name, ...).
17    pub producer: String,
18    /// Opaque producer model family token, read by the cross-family review gate.
19    pub producer_model_family: String,
20    /// Wall-clock time the change-set was staged, supplied by the caller.
21    pub staged_at: Timestamp,
22    /// Opaque producer-assigned batch identifier. When present, a commit
23    /// landing this change-set uses it verbatim as the provenance trailer;
24    /// when absent, the committing tool derives a deterministic fallback
25    /// from `producer` and `staged_at` instead. Optional because the
26    /// identifier is opaque producer-internal tracking — a producer without
27    /// its own batching model is not forced to invent one.
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub batch_id: Option<String>,
30}
31
32impl Envelope {
33    /// Construct an envelope stamped with [`CURRENT_SCHEMA_VERSION`] and no `batch_id`.
34    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    /// Attach a producer-assigned batch identifier.
49    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}