Skip to main content

heddle_object_model/object/
git_note.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Canonical payload carried by `refs/notes/heddle`.
3//!
4//! Git projection owns where the payload is stored and how the notes ref is
5//! updated. The object model owns these durable bytes so projection, ingest,
6//! fsck, and hosted consumers cannot grow independent JSON schemas.
7//!
8//! `source_state`, when present, is the canonical named MessagePack body
9//! (`rmp_serde::to_vec_named`) hex-encoded inside that JSON document.
10
11use serde::{Deserialize, Serialize};
12
13use super::{Agent, State, Status};
14
15/// Portable Heddle metadata attached to a projected Git commit.
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17pub struct HeddleNote {
18    pub state_id: String,
19    pub change_id: String,
20    /// Hex-encoded canonical MessagePack. Absent or null when the note has no
21    /// embedded state. A JSON object is the retired inline `State` and is
22    /// rejected so serde_json never monomorphizes `State`'s deserializer.
23    #[serde(
24        default,
25        skip_serializing_if = "Option::is_none",
26        with = "source_state_msgpack"
27    )]
28    pub source_state: Option<State>,
29    /// Whether Git projection changed the parent graph represented by the
30    /// embedded source state.
31    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
32    pub parents_rewritten: bool,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub agent: Option<Agent>,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub confidence: Option<f32>,
37    /// Either `draft` or `published`.
38    pub status: String,
39    /// Per-scope counts of annotations omitted from a Git export.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub omitted_annotations_breakdown: Option<OmittedBreakdown>,
42    /// Per-module risk-signal counts observed at export time.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub signal_counts: Option<SignalCounts>,
45    /// Author and agent attribution not representable by a Git signature.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub attribution: Option<NoteAttribution>,
48}
49
50#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
51pub struct OmittedBreakdown {
52    #[serde(default)]
53    pub internal: u32,
54    #[serde(default)]
55    pub team: u32,
56    #[serde(default)]
57    pub restricted: u32,
58}
59
60#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
61pub struct SignalCounts {
62    #[serde(default)]
63    pub novelty: u32,
64    #[serde(default)]
65    pub test_reachability: u32,
66    #[serde(default)]
67    pub pattern_deviation: u32,
68    #[serde(default)]
69    pub invariant_adjacency: u32,
70    #[serde(default)]
71    pub self_flagged_uncertainty: u32,
72}
73
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75pub struct NoteAttribution {
76    pub principal_name: String,
77    pub principal_email: String,
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub agent: Option<Agent>,
80}
81
82/// Serde adapter for [`HeddleNote::source_state`].
83///
84/// `None` stays JSON null (and is omitted by `skip_serializing_if`). `Some`
85/// is `hex(rmp_serde::to_vec_named(state))`. JSON objects are not decoded as
86/// `State`; callers must re-export the note with a current heddle.
87mod source_state_msgpack {
88    use serde::de::Error as _;
89    use serde::ser::Error as _;
90    use serde::{Deserialize, Deserializer, Serializer};
91
92    use super::State;
93
94    pub fn serialize<S>(value: &Option<State>, serializer: S) -> Result<S::Ok, S::Error>
95    where
96        S: Serializer,
97    {
98        match value {
99            None => serializer.serialize_none(),
100            Some(state) => {
101                let bytes = rmp_serde::to_vec_named(state).map_err(S::Error::custom)?;
102                serializer.serialize_str(&hex::encode(bytes))
103            }
104        }
105    }
106
107    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<State>, D::Error>
108    where
109        D: Deserializer<'de>,
110    {
111        let Some(value) = Option::<String>::deserialize(deserializer)? else {
112            return Ok(None);
113        };
114        let bytes = hex::decode(value).map_err(D::Error::custom)?;
115        rmp_serde::from_slice(&bytes).map(Some).map_err(D::Error::custom)
116    }
117}
118
119impl HeddleNote {
120    /// Construct the canonical note for a state projected without rewriting
121    /// its parent graph.
122    pub fn from_state(state: &State) -> Self {
123        let status = match state.status {
124            Status::Draft => "draft".to_string(),
125            Status::Published => "published".to_string(),
126        };
127        let agent = state.attribution.agent.clone();
128        Self {
129            state_id: state.id().to_string_full(),
130            change_id: state.change_id.to_string_full(),
131            source_state: Some(state.clone()),
132            parents_rewritten: false,
133            agent,
134            confidence: state.confidence,
135            status,
136            omitted_annotations_breakdown: None,
137            signal_counts: None,
138            attribution: None,
139        }
140    }
141
142    /// Construct a note for a Git projection whose parent graph differs from
143    /// the embedded source state.
144    pub fn from_projected_state(state: &State) -> Self {
145        let mut note = Self::from_state(state);
146        note.parents_rewritten = true;
147        note
148    }
149
150    pub fn with_omitted_breakdown(mut self, breakdown: OmittedBreakdown) -> Self {
151        self.omitted_annotations_breakdown = Some(breakdown);
152        self
153    }
154
155    pub fn with_signal_counts(mut self, counts: SignalCounts) -> Self {
156        self.signal_counts = Some(counts);
157        self
158    }
159
160    pub fn with_attribution(mut self, attribution: NoteAttribution) -> Self {
161        self.attribution = Some(attribution);
162        self
163    }
164
165    /// Encode the one canonical JSON representation written to Git notes.
166    pub fn to_json_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
167        serde_json::to_vec_pretty(self)
168    }
169
170    /// Decode the canonical Git-note representation.
171    pub fn from_json_bytes(bytes: &[u8]) -> Result<Self, serde_json::Error> {
172        let mut note: Self = serde_json::from_slice(bytes)?;
173        if let Some(source_state) = &mut note.source_state {
174            source_state.state_id = source_state.id();
175        }
176        Ok(note)
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use crate::object::{Attribution, Principal, State, Tree};
184
185    fn state() -> State {
186        State::new(
187            Tree::new().hash(),
188            Vec::new(),
189            Attribution::human(Principal::new("Test User", "test@example.com")),
190        )
191    }
192
193    #[test]
194    fn canonical_note_roundtrips_every_field() {
195        let note = HeddleNote::from_projected_state(&state())
196            .with_omitted_breakdown(OmittedBreakdown {
197                internal: 1,
198                team: 2,
199                restricted: 3,
200            })
201            .with_signal_counts(SignalCounts {
202                novelty: 4,
203                test_reachability: 5,
204                pattern_deviation: 6,
205                invariant_adjacency: 7,
206                self_flagged_uncertainty: 8,
207            })
208            .with_attribution(NoteAttribution {
209                principal_name: "Test User".to_string(),
210                principal_email: "test@example.com".to_string(),
211                agent: Some(Agent::new("openai", "codex")),
212            });
213
214        let bytes = note.to_json_bytes().expect("encode canonical note");
215        let encoded: serde_json::Value =
216            serde_json::from_slice(&bytes).expect("note stays a JSON document");
217        let source_state = encoded["source_state"]
218            .as_str()
219            .expect("source_state is hex text, not an embedded State object");
220        assert!(
221            !source_state.is_empty() && source_state.chars().all(|c| c.is_ascii_hexdigit()),
222            "source_state must be hex-encoded MessagePack: {source_state}"
223        );
224        assert_eq!(
225            HeddleNote::from_json_bytes(&bytes).expect("decode canonical note"),
226            note
227        );
228    }
229
230    #[test]
231    fn null_source_state_is_absent() {
232        let source = state();
233        let null_note = serde_json::json!({
234            "state_id": source.id().to_string_full(),
235            "change_id": source.change_id.to_string_full(),
236            "source_state": null,
237            "status": "draft"
238        })
239        .to_string();
240        let note = HeddleNote::from_json_bytes(null_note.as_bytes()).expect("null source_state");
241        assert!(note.source_state.is_none());
242    }
243
244    #[test]
245    fn old_note_without_parent_marker_defaults_to_unmodified_graph() {
246        let source = state();
247        let bytes = serde_json::json!({
248            "state_id": source.id().to_string_full(),
249            "change_id": source.change_id.to_string_full(),
250            "status": "draft"
251        })
252        .to_string();
253
254        let note = HeddleNote::from_json_bytes(bytes.as_bytes()).expect("decode note");
255        assert!(!note.parents_rewritten);
256    }
257
258    #[test]
259    fn foreign_note_missing_required_identity_is_not_a_heddle_note() {
260        let bytes = br#"{"state_id":"hs-deadbeef","status":"published"}"#;
261        assert!(HeddleNote::from_json_bytes(bytes).is_err());
262    }
263}