heddle_object_model/object/
git_note.rs1use serde::{Deserialize, Serialize};
12
13use super::{Agent, State, Status};
14
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17pub struct HeddleNote {
18 pub state_id: String,
19 pub change_id: String,
20 #[serde(
24 default,
25 skip_serializing_if = "Option::is_none",
26 with = "source_state_msgpack"
27 )]
28 pub source_state: Option<State>,
29 #[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 pub status: String,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub omitted_annotations_breakdown: Option<OmittedBreakdown>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub signal_counts: Option<SignalCounts>,
45 #[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
82mod 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 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 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 pub fn to_json_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
167 serde_json::to_vec_pretty(self)
168 }
169
170 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}