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
8use serde::{Deserialize, Serialize};
9
10use super::{State, Status};
11
12/// Portable Heddle metadata attached to a projected Git commit.
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14pub struct HeddleNote {
15    pub state_id: String,
16    pub change_id: String,
17    #[serde(default, skip_serializing_if = "Option::is_none")]
18    pub source_state: Option<State>,
19    /// Whether Git projection changed the parent graph represented by the
20    /// embedded source state.
21    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
22    pub parents_rewritten: bool,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub agent: Option<NoteAgent>,
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub confidence: Option<f32>,
27    /// Either `draft` or `published`.
28    pub status: String,
29    /// Per-scope counts of annotations omitted from a Git export.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub omitted_annotations_breakdown: Option<OmittedBreakdown>,
32    /// Per-module risk-signal counts observed at export time.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub signal_counts: Option<SignalCounts>,
35    /// Author and agent attribution not representable by a Git signature.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub attribution: Option<NoteAttribution>,
38}
39
40#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
41pub struct NoteAgent {
42    pub provider: String,
43    pub model: String,
44}
45
46#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
47pub struct OmittedBreakdown {
48    #[serde(default)]
49    pub internal: u32,
50    #[serde(default)]
51    pub team: u32,
52    #[serde(default)]
53    pub restricted: u32,
54}
55
56#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
57pub struct SignalCounts {
58    #[serde(default)]
59    pub novelty: u32,
60    #[serde(default)]
61    pub test_reachability: u32,
62    #[serde(default)]
63    pub pattern_deviation: u32,
64    #[serde(default)]
65    pub invariant_adjacency: u32,
66    #[serde(default)]
67    pub self_flagged_uncertainty: u32,
68}
69
70#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
71pub struct NoteAttribution {
72    pub principal_name: String,
73    pub principal_email: String,
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub agent: Option<NoteAgent>,
76}
77
78impl HeddleNote {
79    /// Construct the canonical note for a state projected without rewriting
80    /// its parent graph.
81    pub fn from_state(state: &State) -> Self {
82        let status = match state.status {
83            Status::Draft => "draft".to_string(),
84            Status::Published => "published".to_string(),
85        };
86        let agent = state.attribution.agent.as_ref().map(|agent| NoteAgent {
87            provider: agent.provider.clone(),
88            model: agent.model.clone(),
89        });
90        Self {
91            state_id: state.id().to_string_full(),
92            change_id: state.change_id.to_string_full(),
93            source_state: Some(state.clone()),
94            parents_rewritten: false,
95            agent,
96            confidence: state.confidence,
97            status,
98            omitted_annotations_breakdown: None,
99            signal_counts: None,
100            attribution: None,
101        }
102    }
103
104    /// Construct a note for a Git projection whose parent graph differs from
105    /// the embedded source state.
106    pub fn from_projected_state(state: &State) -> Self {
107        let mut note = Self::from_state(state);
108        note.parents_rewritten = true;
109        note
110    }
111
112    pub fn with_omitted_breakdown(mut self, breakdown: OmittedBreakdown) -> Self {
113        self.omitted_annotations_breakdown = Some(breakdown);
114        self
115    }
116
117    pub fn with_signal_counts(mut self, counts: SignalCounts) -> Self {
118        self.signal_counts = Some(counts);
119        self
120    }
121
122    pub fn with_attribution(mut self, attribution: NoteAttribution) -> Self {
123        self.attribution = Some(attribution);
124        self
125    }
126
127    /// Encode the one canonical JSON representation written to Git notes.
128    pub fn to_json_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
129        serde_json::to_vec_pretty(self)
130    }
131
132    /// Decode the canonical Git-note representation.
133    pub fn from_json_bytes(bytes: &[u8]) -> Result<Self, serde_json::Error> {
134        let mut note: Self = serde_json::from_slice(bytes)?;
135        if let Some(source_state) = &mut note.source_state {
136            source_state.state_id = source_state.id();
137        }
138        Ok(note)
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::object::{Attribution, Principal, State, Tree};
146
147    fn state() -> State {
148        State::new(
149            Tree::new().hash(),
150            Vec::new(),
151            Attribution::human(Principal::new("Test User", "test@example.com")),
152        )
153    }
154
155    #[test]
156    fn canonical_note_roundtrips_every_field() {
157        let note = HeddleNote::from_projected_state(&state())
158            .with_omitted_breakdown(OmittedBreakdown {
159                internal: 1,
160                team: 2,
161                restricted: 3,
162            })
163            .with_signal_counts(SignalCounts {
164                novelty: 4,
165                test_reachability: 5,
166                pattern_deviation: 6,
167                invariant_adjacency: 7,
168                self_flagged_uncertainty: 8,
169            })
170            .with_attribution(NoteAttribution {
171                principal_name: "Test User".to_string(),
172                principal_email: "test@example.com".to_string(),
173                agent: Some(NoteAgent {
174                    provider: "openai".to_string(),
175                    model: "codex".to_string(),
176                }),
177            });
178
179        let bytes = note.to_json_bytes().expect("encode canonical note");
180        assert_eq!(
181            HeddleNote::from_json_bytes(&bytes).expect("decode canonical note"),
182            note
183        );
184    }
185
186    #[test]
187    fn old_note_without_parent_marker_defaults_to_unmodified_graph() {
188        let source = state();
189        let bytes = serde_json::json!({
190            "state_id": source.id().to_string_full(),
191            "change_id": source.change_id.to_string_full(),
192            "status": "draft"
193        })
194        .to_string();
195
196        let note = HeddleNote::from_json_bytes(bytes.as_bytes()).expect("decode note");
197        assert!(!note.parents_rewritten);
198    }
199
200    #[test]
201    fn foreign_note_missing_required_identity_is_not_a_heddle_note() {
202        let bytes = br#"{"state_id":"hs-deadbeef","status":"published"}"#;
203        assert!(HeddleNote::from_json_bytes(bytes).is_err());
204    }
205}