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)
116            .map(Some)
117            .map_err(D::Error::custom)
118    }
119}
120
121impl HeddleNote {
122    /// Construct the canonical note for a state projected without rewriting
123    /// its parent graph.
124    pub fn from_state(state: &State) -> Self {
125        let status = match state.status {
126            Status::Draft => "draft".to_string(),
127            Status::Published => "published".to_string(),
128        };
129        let agent = state.attribution.agent.clone();
130        Self {
131            state_id: state.id().to_string_full(),
132            change_id: state.change_id.to_string_full(),
133            source_state: Some(state.clone()),
134            parents_rewritten: false,
135            agent,
136            confidence: state.confidence,
137            status,
138            omitted_annotations_breakdown: None,
139            signal_counts: None,
140            attribution: None,
141        }
142    }
143
144    /// Construct a note for a Git projection whose parent graph differs from
145    /// the embedded source state.
146    pub fn from_projected_state(state: &State) -> Self {
147        let mut note = Self::from_state(state);
148        note.parents_rewritten = true;
149        note
150    }
151
152    pub fn with_omitted_breakdown(mut self, breakdown: OmittedBreakdown) -> Self {
153        self.omitted_annotations_breakdown = Some(breakdown);
154        self
155    }
156
157    pub fn with_signal_counts(mut self, counts: SignalCounts) -> Self {
158        self.signal_counts = Some(counts);
159        self
160    }
161
162    pub fn with_attribution(mut self, attribution: NoteAttribution) -> Self {
163        self.attribution = Some(attribution);
164        self
165    }
166
167    /// Encode the one canonical JSON representation written to Git notes.
168    pub fn to_json_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
169        serde_json::to_vec_pretty(self)
170    }
171
172    /// Decode the canonical Git-note representation.
173    pub fn from_json_bytes(bytes: &[u8]) -> Result<Self, serde_json::Error> {
174        let mut note: Self = serde_json::from_slice(bytes)?;
175        if let Some(source_state) = &mut note.source_state {
176            source_state.state_id = source_state.id();
177        }
178        Ok(note)
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use crate::object::{Attribution, Principal, State, Tree};
186
187    fn state() -> State {
188        State::new(
189            Tree::new().hash(),
190            Vec::new(),
191            Attribution::human(Principal::new("Test User", "test@example.com")),
192        )
193    }
194
195    #[test]
196    fn canonical_note_roundtrips_every_field() {
197        let note = HeddleNote::from_projected_state(&state())
198            .with_omitted_breakdown(OmittedBreakdown {
199                internal: 1,
200                team: 2,
201                restricted: 3,
202            })
203            .with_signal_counts(SignalCounts {
204                novelty: 4,
205                test_reachability: 5,
206                pattern_deviation: 6,
207                invariant_adjacency: 7,
208                self_flagged_uncertainty: 8,
209            })
210            .with_attribution(NoteAttribution {
211                principal_name: "Test User".to_string(),
212                principal_email: "test@example.com".to_string(),
213                agent: Some(Agent::new("openai", "codex")),
214            });
215
216        let bytes = note.to_json_bytes().expect("encode canonical note");
217        let encoded: serde_json::Value =
218            serde_json::from_slice(&bytes).expect("note stays a JSON document");
219        let source_state = encoded["source_state"]
220            .as_str()
221            .expect("source_state is hex text, not an embedded State object");
222        assert!(
223            !source_state.is_empty() && source_state.chars().all(|c| c.is_ascii_hexdigit()),
224            "source_state must be hex-encoded MessagePack: {source_state}"
225        );
226        assert_eq!(
227            HeddleNote::from_json_bytes(&bytes).expect("decode canonical note"),
228            note
229        );
230    }
231
232    #[test]
233    fn null_source_state_is_absent() {
234        let source = state();
235        let null_note = serde_json::json!({
236            "state_id": source.id().to_string_full(),
237            "change_id": source.change_id.to_string_full(),
238            "source_state": null,
239            "status": "draft"
240        })
241        .to_string();
242        let note = HeddleNote::from_json_bytes(null_note.as_bytes()).expect("null source_state");
243        assert!(note.source_state.is_none());
244    }
245
246    #[test]
247    fn old_note_without_parent_marker_defaults_to_unmodified_graph() {
248        let source = state();
249        let bytes = serde_json::json!({
250            "state_id": source.id().to_string_full(),
251            "change_id": source.change_id.to_string_full(),
252            "status": "draft"
253        })
254        .to_string();
255
256        let note = HeddleNote::from_json_bytes(bytes.as_bytes()).expect("decode note");
257        assert!(!note.parents_rewritten);
258    }
259
260    #[test]
261    fn foreign_note_missing_required_identity_is_not_a_heddle_note() {
262        let bytes = br#"{"state_id":"hs-deadbeef","status":"published"}"#;
263        assert!(HeddleNote::from_json_bytes(bytes).is_err());
264    }
265}