1use std::collections::BTreeMap;
6
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9
10use super::{Attribution, ChangeId, ContentHash, Principal, StateId};
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
16pub enum Status {
17 #[default]
18 Draft,
19 Published,
20}
21
22impl Status {
23 pub fn to_byte(&self) -> u8 {
24 match self {
25 Status::Draft => 0,
26 Status::Published => 1,
27 }
28 }
29
30 pub fn from_byte(b: u8) -> Option<Self> {
31 match b {
32 0 => Some(Status::Draft),
33 1 => Some(Status::Published),
34 _ => None,
35 }
36 }
37}
38
39#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
40pub enum ChangeLineageKind {
41 CherryPick,
42 Collapse,
43 Revert,
44 GitProjection,
45}
46
47impl ChangeLineageKind {
48 fn to_byte(self) -> u8 {
49 match self {
50 Self::CherryPick => 1,
51 Self::Collapse => 2,
52 Self::Revert => 3,
53 Self::GitProjection => 4,
54 }
55 }
56}
57
58#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
59pub struct ChangeLineage {
60 pub kind: ChangeLineageKind,
61 pub source_change: ChangeId,
62 pub source_state: StateId,
63}
64
65#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
69pub struct StateSignature {
70 pub algorithm: String,
71 pub public_key: String,
72 pub signature: String,
73}
74
75impl StateSignature {
76 pub fn algorithm(&self) -> &str {
77 &self.algorithm
78 }
79}
80
81#[derive(Clone, Copy, Debug, PartialEq, Eq)]
83pub enum SignatureStatus {
84 Valid,
85 Legacy,
86 Invalid,
87 Unsigned,
88}
89
90impl SignatureStatus {
91 pub fn is_valid(self) -> bool {
92 self == SignatureStatus::Valid
93 }
94
95 pub fn is_unsigned(self) -> bool {
96 self == SignatureStatus::Unsigned
97 }
98
99 pub fn is_legacy(self) -> bool {
100 self == SignatureStatus::Legacy
101 }
102}
103
104#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
108pub struct Verification {
109 pub tests_passed: Option<bool>,
110 pub tests_failed: Option<u32>,
111 pub coverage_pct: Option<f32>,
112 pub coverage_delta: Option<f32>,
113 pub lint_warnings: Option<u32>,
114 #[serde(default)]
115 pub custom: BTreeMap<String, serde_json::Value>,
116}
117
118impl Verification {
119 pub fn new() -> Self {
120 Self::default()
121 }
122
123 pub fn with_tests_passed(mut self, passed: bool) -> Self {
124 self.tests_passed = Some(passed);
125 self
126 }
127
128 pub fn with_tests_failed(mut self, failed: u32) -> Self {
129 self.tests_failed = Some(failed);
130 self
131 }
132
133 pub fn is_empty(&self) -> bool {
134 self.tests_passed.is_none()
135 && self.tests_failed.is_none()
136 && self.coverage_pct.is_none()
137 && self.coverage_delta.is_none()
138 && self.lint_warnings.is_none()
139 && self.custom.is_empty()
140 }
141
142 pub(crate) fn hash_len(&self) -> usize {
143 let mut len = 0;
144 len += 1 + self.tests_passed.map(|_| 1).unwrap_or(0);
145 len += 1 + self.tests_failed.map(|_| 4).unwrap_or(0);
146 len += 1 + self.coverage_pct.map(|_| 4).unwrap_or(0);
147 len += 1 + self.coverage_delta.map(|_| 4).unwrap_or(0);
148 len += 1 + self.lint_warnings.map(|_| 4).unwrap_or(0);
149 len += 4;
150 for (key, value) in &self.custom {
151 let value_bytes = serde_json::to_vec(value).unwrap_or_default();
152 len += 4 + key.len();
153 len += 4 + value_bytes.len();
154 }
155 len
156 }
157
158 pub(crate) fn update_hasher(&self, hasher: &mut blake3::Hasher) {
159 let tests_passed = self.tests_passed.map(u8::from);
160 write_optional_u8(hasher, tests_passed);
161 write_optional_u32(hasher, self.tests_failed);
162 write_optional_f32(hasher, self.coverage_pct);
163 write_optional_f32(hasher, self.coverage_delta);
164 write_optional_u32(hasher, self.lint_warnings);
165 let custom_len = self.custom.len() as u32;
166 hasher.update(&custom_len.to_le_bytes());
167 for (key, value) in &self.custom {
168 let key_bytes = key.as_bytes();
169 let value_bytes = serde_json::to_vec(value).unwrap_or_default();
170 hasher.update(&(key_bytes.len() as u32).to_le_bytes());
171 hasher.update(key_bytes);
172 hasher.update(&(value_bytes.len() as u32).to_le_bytes());
173 hasher.update(&value_bytes);
174 }
175 }
176}
177
178fn write_optional_u8(hasher: &mut blake3::Hasher, value: Option<u8>) {
179 match value {
180 Some(v) => {
181 hasher.update(&[1]);
182 hasher.update(&[v]);
183 }
184 None => {
185 hasher.update(&[0]);
186 }
187 }
188}
189
190fn write_optional_u32(hasher: &mut blake3::Hasher, value: Option<u32>) {
191 match value {
192 Some(v) => {
193 hasher.update(&[1]);
194 hasher.update(&v.to_le_bytes());
195 }
196 None => {
197 hasher.update(&[0]);
198 }
199 }
200}
201
202fn write_optional_f32(hasher: &mut blake3::Hasher, value: Option<f32>) {
203 match value {
204 Some(v) => {
205 hasher.update(&[1]);
206 hasher.update(&v.to_le_bytes());
207 }
208 None => {
209 hasher.update(&[0]);
210 }
211 }
212}
213
214#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
219pub struct State {
220 #[serde(skip)]
221 pub state_id: StateId,
222 pub change_id: ChangeId,
223 pub tree: ContentHash,
224 pub parents: Vec<StateId>,
225 pub attribution: Attribution,
226 pub intent: Option<String>,
227 pub confidence: Option<f32>,
228 pub created_at: DateTime<Utc>,
229 pub verification: Option<Verification>,
230 pub status: Status,
231 #[serde(default)]
233 pub provenance: Option<ContentHash>,
234 #[serde(default)]
252 pub authored_at: Option<DateTime<Utc>>,
253 #[serde(default)]
270 pub committer: Option<Principal>,
271 #[serde(default)]
276 pub authored_tz_offset: i32,
277 #[serde(default)]
280 pub committer_tz_offset: i32,
281 #[serde(default)]
293 pub raw_message: Option<Vec<u8>>,
294 #[serde(default)]
309 pub git_lossy: bool,
310 #[serde(default)]
329 pub extra_headers: Vec<(Vec<u8>, Vec<u8>)>,
330 pub lineage: Vec<ChangeLineage>,
331}
332
333impl State {
334 pub fn new(tree: ContentHash, parents: Vec<StateId>, attribution: Attribution) -> Self {
335 Self::new_snapshot(tree, parents, attribution)
336 }
337
338 pub fn new_snapshot(
339 tree: ContentHash,
340 parents: Vec<StateId>,
341 attribution: Attribution,
342 ) -> Self {
343 Self::new_with_change_id(tree, parents, attribution, ChangeId::generate())
344 }
345
346 pub fn new_merge(tree: ContentHash, parents: Vec<StateId>, attribution: Attribution) -> Self {
347 Self::new_snapshot(tree, parents, attribution)
348 }
349
350 pub fn new_refresh_of(
351 tree: ContentHash,
352 parents: Vec<StateId>,
353 attribution: Attribution,
354 change_id: ChangeId,
355 ) -> Self {
356 Self::new_with_change_id(tree, parents, attribution, change_id)
357 }
358
359 pub fn new_fork_of(tree: ContentHash, parents: Vec<StateId>, attribution: Attribution) -> Self {
360 Self::new_snapshot(tree, parents, attribution)
361 }
362
363 pub fn new_collapse_of(
364 tree: ContentHash,
365 parents: Vec<StateId>,
366 attribution: Attribution,
367 ) -> Self {
368 Self::new_snapshot(tree, parents, attribution)
369 }
370
371 fn new_with_change_id(
372 tree: ContentHash,
373 parents: Vec<StateId>,
374 attribution: Attribution,
375 change_id: ChangeId,
376 ) -> Self {
377 let mut state = Self {
378 state_id: StateId::default(),
379 change_id,
380 tree,
381 parents,
382 attribution,
383 intent: None,
384 confidence: None,
385 created_at: Utc::now(),
386 verification: None,
387 provenance: None,
388 authored_at: None,
389 committer: None,
390 authored_tz_offset: 0,
391 committer_tz_offset: 0,
392 raw_message: None,
393 git_lossy: false,
394 extra_headers: Vec::new(),
395 lineage: Vec::new(),
396 status: Status::Draft,
397 };
398 state.refresh_state_id();
399 state
400 }
401
402 pub fn with_intent(mut self, intent: impl Into<String>) -> Self {
403 self.intent = Some(intent.into());
404 self.refresh_state_id();
405 self
406 }
407
408 pub fn with_confidence(mut self, confidence: f32) -> Self {
409 self.confidence = Some(confidence.clamp(0.0, 1.0));
410 self.refresh_state_id();
411 self
412 }
413
414 pub fn with_verification(mut self, verification: Verification) -> Self {
415 self.verification = Some(verification);
416 self.refresh_state_id();
417 self
418 }
419
420 pub fn with_provenance(mut self, provenance: ContentHash) -> Self {
421 self.provenance = Some(provenance);
422 self.refresh_state_id();
423 self
424 }
425
426 pub fn with_authored_at(mut self, timestamp: DateTime<Utc>) -> Self {
437 self.authored_at = Some(timestamp);
438 self.refresh_state_id();
439 self
440 }
441
442 pub fn with_committer(mut self, committer: Principal) -> Self {
447 self.committer = Some(committer);
448 self.refresh_state_id();
449 self
450 }
451
452 pub fn with_tz_offsets(mut self, authored: i32, committer: i32) -> Self {
455 self.authored_tz_offset = authored;
456 self.committer_tz_offset = committer;
457 self.refresh_state_id();
458 self
459 }
460
461 pub fn with_raw_message(mut self, raw_message: impl AsRef<[u8]>) -> Self {
465 self.raw_message = Some(raw_message.as_ref().to_vec());
466 self.refresh_state_id();
467 self
468 }
469
470 pub fn with_git_lossy(mut self, git_lossy: bool) -> Self {
476 self.git_lossy = git_lossy;
477 self.refresh_state_id();
478 self
479 }
480
481 pub fn with_extra_headers(mut self, extra_headers: Vec<(Vec<u8>, Vec<u8>)>) -> Self {
485 self.extra_headers = extra_headers;
486 self.refresh_state_id();
487 self
488 }
489
490 pub fn with_lineage(mut self, lineage: Vec<ChangeLineage>) -> Self {
491 self.lineage = lineage;
492 self.refresh_state_id();
493 self
494 }
495
496 pub fn with_status(mut self, status: Status) -> Self {
497 self.status = status;
498 self.refresh_state_id();
499 self
500 }
501
502 pub fn with_change_id(mut self, change_id: ChangeId) -> Self {
503 self.change_id = change_id;
504 self.refresh_state_id();
505 self
506 }
507
508 pub fn with_timestamp(mut self, timestamp: DateTime<Utc>) -> Self {
509 self.created_at = timestamp;
510 self.refresh_state_id();
511 self
512 }
513
514 pub fn compute_hash(&self) -> ContentHash {
515 let content_len = self.hash_len();
516 ContentHash::compute_typed_with_len("state", content_len, |hasher| {
517 self.update_hash(hasher);
518 })
519 }
520
521 pub fn hash(&mut self) -> ContentHash {
522 self.refresh_state_id();
523 self.state_id.as_content_hash()
524 }
525
526 pub fn id(&self) -> StateId {
527 StateId::from_content_hash(self.compute_hash())
528 }
529
530 pub fn pre_cursor_id(&self) -> StateId {
533 StateId::from_content_hash(self.compute_pre_cursor_hash())
534 }
535
536 pub fn accepts_stored_id(&self, stored: &StateId) -> bool {
539 if self.id() == *stored {
540 return true;
541 }
542 let unpublished_cursor = self
543 .attribution
544 .agent
545 .as_ref()
546 .is_none_or(|agent| agent.thought_level.is_none() && agent.parent.is_none());
547 unpublished_cursor && self.pre_cursor_id() == *stored
548 }
549
550 pub fn hash_for_stored_id(&self, stored: &StateId) -> ContentHash {
555 if self.id() == *stored {
556 self.compute_hash()
557 } else if self.accepts_stored_id(stored) {
558 self.compute_pre_cursor_hash()
559 } else {
560 self.compute_hash()
561 }
562 }
563
564 pub fn is_root(&self) -> bool {
565 self.parents.is_empty()
566 }
567
568 pub fn is_merge(&self) -> bool {
569 self.parents.len() > 1
570 }
571
572 pub fn is_agent_authored(&self) -> bool {
573 self.attribution.agent.is_some()
574 }
575
576 pub fn first_parent(&self) -> Option<&StateId> {
577 self.parents.first()
578 }
579
580 fn hash_len(&self) -> u64 {
581 self.hash_len_core() + self.hash_len_fidelity()
582 }
583
584 fn hash_len_pre_cursor(&self) -> u64 {
585 self.hash_len_core_pre_cursor() + self.hash_len_fidelity()
586 }
587
588 fn hash_len_core(&self) -> u64 {
590 self.hash_len_core_versioned(true)
591 }
592
593 fn hash_len_core_pre_cursor(&self) -> u64 {
594 self.hash_len_core_versioned(false)
595 }
596
597 fn hash_len_core_versioned(&self, include_cursor_fields: bool) -> u64 {
598 let principal = &self.attribution.principal;
599 let mut len = 0u64;
600
601 len += 16;
602
603 len += self.tree.as_bytes().len() as u64;
604 len += 4;
605 len += (self.parents.len() * 32) as u64;
606
607 len += principal.name.len() as u64 + 1;
608 len += principal.email.len() as u64 + 1;
609
610 len += 1;
611 if let Some(agent) = &self.attribution.agent {
612 len += agent.provider.len() as u64 + 1;
613 len += agent.model.len() as u64 + 1;
614
615 len += 1;
616 if let Some(session_id) = &agent.session_id {
617 len += session_id.len() as u64 + 1;
618 }
619
620 len += 1;
621 if let Some(segment_id) = &agent.segment_id {
622 len += segment_id.len() as u64 + 1;
623 }
624
625 len += 1;
626 if let Some(policy_id) = &agent.policy_id {
627 len += policy_id.len() as u64 + 1;
628 }
629
630 if include_cursor_fields {
631 len += 1;
632 if let Some(thought_level) = &agent.thought_level {
633 len += thought_level.len() as u64 + 1;
634 }
635
636 len += 1;
637 if let Some(parent) = &agent.parent {
638 len += parent.len() as u64 + 1;
639 }
640 }
641 }
642
643 len += 1;
644 if let Some(intent) = &self.intent {
645 len += intent.len() as u64 + 1;
646 }
647
648 len += 1;
649 if self.confidence.is_some() {
650 len += 4;
651 }
652
653 len += 8;
654
655 len += 1;
656 if let Some(verification) = &self.verification {
657 len += verification.hash_len() as u64;
658 }
659
660 len += 1;
661 if self.provenance.is_some() {
662 len += 32;
663 }
664
665 len += 1;
666
667 len
668 }
669
670 fn hash_len_fidelity(&self) -> u64 {
675 let mut len = 0u64;
676
677 len += 1;
680 if let Some(committer) = &self.committer {
681 len += committer.name.len() as u64 + 1;
682 len += committer.email.len() as u64 + 1;
683 }
684 len += 4;
686 len += 4;
687 len += 1;
689 if self.authored_at.is_some() {
690 len += 8;
691 }
692 len += 1;
696 if let Some(raw_message) = &self.raw_message {
697 len += 4 + raw_message.len() as u64;
698 }
699 len += 4;
702 for (key, value) in &self.extra_headers {
703 len += 4 + key.len() as u64;
704 len += 4 + value.len() as u64;
705 }
706 len += 4 + (self.lineage.len() as u64 * 49);
707
708 len
709 }
710
711 fn update_hash(&self, hasher: &mut blake3::Hasher) {
712 self.update_hash_core(hasher);
713 self.update_hash_fidelity(hasher);
714 }
715
716 fn compute_pre_cursor_hash(&self) -> ContentHash {
717 let content_len = self.hash_len_pre_cursor();
718 ContentHash::compute_typed_with_len("state", content_len, |hasher| {
719 self.update_hash_pre_cursor(hasher);
720 })
721 }
722
723 fn update_hash_pre_cursor(&self, hasher: &mut blake3::Hasher) {
724 self.update_hash_core_pre_cursor(hasher);
725 self.update_hash_fidelity(hasher);
726 }
727
728 fn update_hash_core(&self, hasher: &mut blake3::Hasher) {
732 self.update_hash_core_versioned(hasher, true);
733 }
734
735 fn update_hash_core_pre_cursor(&self, hasher: &mut blake3::Hasher) {
736 self.update_hash_core_versioned(hasher, false);
737 }
738
739 fn update_hash_core_versioned(&self, hasher: &mut blake3::Hasher, include_cursor_fields: bool) {
740 let principal = &self.attribution.principal;
741
742 hasher.update(self.change_id.as_bytes());
743
744 hasher.update(self.tree.as_bytes());
745 hasher.update(&(self.parents.len() as u32).to_le_bytes());
746 for parent in &self.parents {
747 hasher.update(parent.as_bytes());
748 }
749
750 hasher.update(&principal.name);
751 hasher.update(&[0]);
752 hasher.update(&principal.email);
753 hasher.update(&[0]);
754
755 if let Some(agent) = &self.attribution.agent {
756 hasher.update(&[1]);
757 hasher.update(agent.provider.as_bytes());
758 hasher.update(&[0]);
759 hasher.update(agent.model.as_bytes());
760 hasher.update(&[0]);
761 write_optional_string(hasher, &agent.session_id);
762 write_optional_string(hasher, &agent.segment_id);
763 write_optional_string(hasher, &agent.policy_id);
764 if include_cursor_fields {
765 write_optional_string(hasher, &agent.thought_level);
766 write_optional_string(hasher, &agent.parent);
767 }
768 } else {
769 hasher.update(&[0]);
770 }
771
772 write_optional_string(hasher, &self.intent);
773
774 if let Some(confidence) = self.confidence {
775 hasher.update(&[1]);
776 hasher.update(&confidence.to_le_bytes());
777 } else {
778 hasher.update(&[0]);
779 }
780
781 hasher.update(&self.created_at.timestamp().to_le_bytes());
782
783 if let Some(verification) = &self.verification {
784 hasher.update(&[1]);
785 verification.update_hasher(hasher);
786 } else {
787 hasher.update(&[0]);
788 }
789
790 if let Some(provenance) = self.provenance {
791 hasher.update(&[1]);
792 hasher.update(provenance.as_bytes());
793 } else {
794 hasher.update(&[0]);
795 }
796
797 hasher.update(&[self.status.to_byte()]);
798 }
799
800 fn update_hash_fidelity(&self, hasher: &mut blake3::Hasher) {
814 if let Some(committer) = &self.committer {
815 hasher.update(&[1]);
816 hasher.update(&committer.name);
817 hasher.update(&[0]);
818 hasher.update(&committer.email);
819 hasher.update(&[0]);
820 } else {
821 hasher.update(&[0]);
822 }
823
824 hasher.update(&self.authored_tz_offset.to_le_bytes());
825 hasher.update(&self.committer_tz_offset.to_le_bytes());
826
827 if let Some(authored_at) = self.authored_at {
830 hasher.update(&[1]);
831 hasher.update(&authored_at.timestamp().to_le_bytes());
832 } else {
833 hasher.update(&[0]);
834 }
835
836 write_optional_bytes(hasher, &self.raw_message);
837
838 hasher.update(&(self.extra_headers.len() as u32).to_le_bytes());
840 for (key, value) in &self.extra_headers {
841 hasher.update(&(key.len() as u32).to_le_bytes());
842 hasher.update(key);
843 hasher.update(&(value.len() as u32).to_le_bytes());
844 hasher.update(value);
845 }
846 hasher.update(&(self.lineage.len() as u32).to_le_bytes());
847 for lineage in &self.lineage {
848 hasher.update(&[lineage.kind.to_byte()]);
849 hasher.update(lineage.source_change.as_bytes());
850 hasher.update(lineage.source_state.as_bytes());
851 }
852 }
853
854 fn refresh_state_id(&mut self) {
855 self.state_id = StateId::from_content_hash(self.compute_hash());
856 }
857}
858
859fn write_optional_bytes(hasher: &mut blake3::Hasher, value: &Option<Vec<u8>>) {
865 match value {
866 Some(bytes) => {
867 hasher.update(&[1]);
868 hasher.update(&(bytes.len() as u32).to_le_bytes());
869 hasher.update(bytes);
870 }
871 None => {
872 hasher.update(&[0]);
873 }
874 }
875}
876
877fn write_optional_string(hasher: &mut blake3::Hasher, value: &Option<String>) {
878 match value {
879 Some(value) => {
880 hasher.update(&[1]);
881 hasher.update(value.as_bytes());
882 hasher.update(&[0]);
883 }
884 None => {
885 hasher.update(&[0]);
886 }
887 }
888}
889
890pub fn parse_commit_extension_headers(commit_content: &[u8]) -> Vec<(Vec<u8>, Vec<u8>)> {
919 let header_block = match find_subslice(commit_content, b"\n\n") {
923 Some(idx) => &commit_content[..idx],
924 None => commit_content,
926 };
927
928 let mut headers: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
931 for line in header_block.split(|&b| b == b'\n') {
932 if line.first() == Some(&b' ') {
933 if let Some((_, value)) = headers.last_mut() {
936 value.push(b'\n');
937 value.extend_from_slice(&line[1..]);
938 }
939 continue;
942 }
943 let (name, value) = match line.iter().position(|&b| b == b' ') {
947 Some(sp) => (line[..sp].to_vec(), line[sp + 1..].to_vec()),
948 None => (line.to_vec(), Vec::new()),
949 };
950 headers.push((name, value));
951 }
952
953 match headers.iter().position(|(name, _)| name == b"committer") {
958 Some(idx) => headers.split_off(idx + 1),
959 None => headers
960 .into_iter()
961 .filter(|(name, _)| {
962 !matches!(
963 name.as_slice(),
964 b"tree" | b"parent" | b"author" | b"committer"
965 )
966 })
967 .collect(),
968 }
969}
970
971fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
973 if needle.is_empty() || needle.len() > haystack.len() {
974 return None;
975 }
976 haystack.windows(needle.len()).position(|w| w == needle)
977}
978
979#[cfg(test)]
980mod tests {
981 use super::*;
982 use crate::object::Principal;
983
984 fn sample_attribution() -> Attribution {
985 Attribution::human(Principal::new("Alice", "alice@example.com"))
986 }
987
988 #[test]
989 fn format4_agent_states_keep_pre_cursor_id_when_cursor_fields_are_unpublished() {
990 use crate::object::Agent;
991
992 let created_at = DateTime::from_timestamp(1_700_000_000, 0).expect("fixed test timestamp");
993 let tree = ContentHash::from_bytes([11; 32]);
994 let mut agent_state = State::new(
995 tree,
996 Vec::new(),
997 Attribution::with_agent(
998 Principal::new("Author", "author@example.com"),
999 Agent::new("anthropic", "opus"),
1000 ),
1001 );
1002 agent_state.created_at = created_at;
1003 agent_state.state_id = agent_state.id();
1004 assert_ne!(
1005 agent_state.id(),
1006 agent_state.pre_cursor_id(),
1007 "None cursor tags must change the current id of every agent state"
1008 );
1009 assert!(
1010 agent_state.accepts_stored_id(&agent_state.pre_cursor_id()),
1011 "format-4 agent ids must still validate after the cursor hash bump"
1012 );
1013 assert!(agent_state.accepts_stored_id(&agent_state.id()));
1014
1015 let mut published = agent_state.clone();
1016 published.attribution.agent = Some(
1017 Agent::new("anthropic", "opus")
1018 .with_thought_level("high")
1019 .with_parent("agent-1"),
1020 );
1021 published.state_id = published.id();
1022 assert!(
1023 !published.accepts_stored_id(&agent_state.pre_cursor_id()),
1024 "published cursor fields must not validate against a format-4 id"
1025 );
1026 assert_ne!(published.id(), agent_state.id());
1027
1028 let mut human = State::new(
1029 tree,
1030 Vec::new(),
1031 Attribution::human(Principal::new("Author", "author@example.com")),
1032 );
1033 human.created_at = created_at;
1034 assert_eq!(
1035 human.id(),
1036 human.pre_cursor_id(),
1037 "human states never hashed the agent cursor tags"
1038 );
1039 assert_eq!(
1040 agent_state.hash_for_stored_id(&agent_state.pre_cursor_id()),
1041 agent_state.compute_pre_cursor_hash(),
1042 "accepted format-4 ids must verify against the preserved hash"
1043 );
1044 assert_eq!(
1045 agent_state.hash_for_stored_id(&agent_state.id()),
1046 agent_state.compute_hash()
1047 );
1048 }
1049
1050 #[test]
1051 fn new_snapshot_sets_fresh_logical_identity() {
1052 let state =
1053 State::new_snapshot(ContentHash::compute(b"tree"), vec![], sample_attribution());
1054 assert!(!state.change_id.is_zero());
1055 assert_eq!(state.state_id, state.id());
1056 }
1057
1058 #[test]
1059 fn new_refresh_preserves_explicit_logical_identity() {
1060 let logical_change_id = ChangeId::from_bytes([7; 16]);
1061 let state = State::new_refresh_of(
1062 ContentHash::compute(b"tree"),
1063 vec![],
1064 sample_attribution(),
1065 logical_change_id,
1066 );
1067 assert_eq!(state.change_id, logical_change_id);
1068 }
1069
1070 #[test]
1071 fn new_merge_uses_fresh_logical_identity() {
1072 let state = State::new_merge(
1073 ContentHash::compute(b"tree"),
1074 vec![StateId::from_bytes([1; 32]), StateId::from_bytes([2; 32])],
1075 sample_attribution(),
1076 );
1077 assert!(!state.change_id.is_zero());
1078 assert!(state.is_merge());
1079 }
1080
1081 #[test]
1082 fn with_change_id_invalidates_cached_hash_when_logical_identity_changes() {
1083 let mut state =
1084 State::new_snapshot(ContentHash::compute(b"tree"), vec![], sample_attribution());
1085 let original_hash = state.hash();
1086 let replacement = ChangeId::from_bytes([9; 16]);
1087
1088 let mut updated = state.with_change_id(replacement);
1089
1090 assert_eq!(updated.change_id, replacement);
1091 assert_ne!(updated.hash(), original_hash);
1092 assert_eq!(updated.hash(), updated.compute_hash());
1093 }
1094
1095 #[test]
1096 fn agent_segment_is_part_of_state_hash() {
1097 let principal = Principal::new("Alice", "alice@example.com");
1098 let attribution_a = Attribution::with_agent(
1099 principal.clone(),
1100 crate::object::Agent::new("openai", "gpt-5").with_session("sess-1", "seg-1"),
1101 );
1102 let attribution_b = Attribution::with_agent(
1103 principal,
1104 crate::object::Agent::new("openai", "gpt-5").with_session("sess-1", "seg-2"),
1105 );
1106 let tree = ContentHash::compute(b"tree");
1107 let timestamp = Utc::now();
1108 let logical_change_id = ChangeId::from_bytes([3; 16]);
1109 let state_a = State::new_snapshot(tree, vec![], attribution_a)
1110 .with_change_id(logical_change_id)
1111 .with_timestamp(timestamp);
1112 let state_b = State::new_snapshot(tree, vec![], attribution_b)
1113 .with_change_id(logical_change_id)
1114 .with_timestamp(timestamp);
1115
1116 assert_ne!(state_a.compute_hash(), state_b.compute_hash());
1117 }
1118
1119 #[test]
1120 fn agent_segment_is_included_in_state_hash_length_prefix() {
1121 let state = State::new_snapshot(
1122 ContentHash::compute(b"tree"),
1123 vec![],
1124 Attribution::with_agent(
1125 Principal::new("Alice", "alice@example.com"),
1126 crate::object::Agent::new("openai", "gpt-5").with_session("sess-1", "segment-1"),
1127 ),
1128 );
1129 let segment_len = "segment-1".len() as u64 + 2;
1130 let missing_segment_len_hash = ContentHash::compute_typed_with_len(
1131 "state",
1132 state.hash_len() - segment_len,
1133 |hasher| state.update_hash(hasher),
1134 );
1135
1136 assert_ne!(
1137 state.compute_hash(),
1138 missing_segment_len_hash,
1139 "segment_id's option tag, bytes, and terminator must affect the typed length prefix",
1140 );
1141 }
1142
1143 fn sample_state() -> State {
1144 State::new_snapshot(ContentHash::compute(b"tree"), vec![], sample_attribution())
1145 }
1146
1147 fn assert_mutator_invalidates_cached_hash(
1148 mut state: State,
1149 mutate: impl FnOnce(State) -> State,
1150 ) {
1151 let original_hash = state.hash();
1152 let mut updated = mutate(state);
1153 assert_ne!(updated.hash(), original_hash);
1154 assert_eq!(updated.hash(), updated.compute_hash());
1155 }
1156
1157 #[test]
1158 fn with_intent_invalidates_cached_hash() {
1159 assert_mutator_invalidates_cached_hash(sample_state(), |state| {
1160 state.with_intent("capture intent")
1161 });
1162 }
1163
1164 #[test]
1165 fn with_confidence_invalidates_cached_hash() {
1166 assert_mutator_invalidates_cached_hash(sample_state(), |state| state.with_confidence(0.9));
1167 }
1168
1169 #[test]
1170 fn with_verification_invalidates_cached_hash() {
1171 assert_mutator_invalidates_cached_hash(sample_state(), |state| {
1172 state.with_verification(Verification::new().with_tests_passed(true))
1173 });
1174 }
1175
1176 #[test]
1177 fn with_status_invalidates_cached_hash() {
1178 assert_mutator_invalidates_cached_hash(sample_state(), |state| {
1179 state.with_status(Status::Published)
1180 });
1181 }
1182
1183 #[test]
1184 fn with_timestamp_invalidates_cached_hash() {
1185 assert_mutator_invalidates_cached_hash(sample_state(), |state| {
1186 state.with_timestamp(Utc::now() + chrono::Duration::seconds(1))
1187 });
1188 }
1189
1190 #[test]
1194 fn fidelity_fields_are_part_of_state_hash() {
1195 let base = sample_state();
1196 let base_hash = base.compute_hash();
1197
1198 let with_committer = sample_state().with_change_id(base.change_id);
1199 let mut with_committer =
1200 with_committer.with_committer(Principal::new("Carol", "carol@example.com"));
1201 with_committer.created_at = base.created_at;
1202 assert_ne!(
1203 with_committer.hash(),
1204 base_hash,
1205 "committer must affect the state hash"
1206 );
1207
1208 for mutate in [
1209 |s: State| s.with_tz_offsets(3600, -7200),
1210 |s: State| s.with_authored_at(Utc::now() + chrono::Duration::seconds(1)),
1211 |s: State| s.with_raw_message("verbatim body\n"),
1212 |s: State| {
1214 s.with_extra_headers(vec![(
1215 b"gpgsig".to_vec(),
1216 b"-----BEGIN PGP SIGNATURE-----\n".to_vec(),
1217 )])
1218 },
1219 |s: State| s.with_extra_headers(vec![(b"mergetag".to_vec(), b"x".to_vec())]),
1220 ] {
1221 let seeded = sample_state().with_change_id(base.change_id);
1222 let mut decorated = mutate(seeded);
1223 decorated.created_at = base.created_at;
1224 assert_ne!(
1225 decorated.hash(),
1226 base_hash,
1227 "fidelity field must affect the state hash"
1228 );
1229 }
1230 }
1231
1232 #[test]
1235 fn extra_headers_order_affects_hash() {
1236 let base = sample_state();
1237 let one = sample_state().with_change_id(base.change_id);
1238 let mut one = one.with_extra_headers(vec![
1239 (b"a".to_vec(), b"1".to_vec()),
1240 (b"b".to_vec(), b"2".to_vec()),
1241 ]);
1242 one.created_at = base.created_at;
1243
1244 let two = sample_state().with_change_id(base.change_id);
1245 let mut two = two.with_extra_headers(vec![
1246 (b"b".to_vec(), b"2".to_vec()),
1247 (b"a".to_vec(), b"1".to_vec()),
1248 ]);
1249 two.created_at = base.created_at;
1250
1251 assert_ne!(one.hash(), two.hash());
1252 }
1253
1254 #[test]
1258 fn fidelity_fields_hash_is_stable() {
1259 let mut state = sample_state()
1260 .with_committer(Principal::new("Dave", "dave@example.com"))
1261 .with_tz_offsets(3600, 0)
1262 .with_authored_at(Utc::now())
1263 .with_raw_message("body\n")
1264 .with_extra_headers(vec![
1265 (b"gpgsig".to_vec(), b"sig".to_vec()),
1266 (b"k".to_vec(), b"v".to_vec()),
1267 ]);
1268 assert_eq!(state.hash(), state.compute_hash());
1269 }
1270
1271 #[test]
1276 fn non_utf8_raw_message_is_byte_preserved() {
1277 let raw = b"caf\xe9\n".to_vec();
1278 assert!(
1279 String::from_utf8(raw.clone()).is_err(),
1280 "test fixture must be invalid UTF-8 to be meaningful"
1281 );
1282 let mut state = sample_state().with_raw_message(&raw);
1283 assert_eq!(
1284 state.raw_message.as_deref(),
1285 Some(raw.as_slice()),
1286 "raw bytes preserved verbatim"
1287 );
1288 let bytes = rmp_serde::to_vec(&state).expect("serialize state");
1291 let back: State = rmp_serde::from_slice(&bytes).expect("deserialize state");
1292 assert_eq!(back.raw_message.as_deref(), Some(raw.as_slice()));
1293 let mut back = back;
1294 assert_eq!(state.hash(), back.hash());
1295 assert_eq!(back.hash(), back.compute_hash());
1296 }
1297
1298 #[test]
1302 fn raw_message_with_nul_byte_changes_hash() {
1303 let base = sample_state();
1304 let with_nul = sample_state().with_change_id(base.change_id);
1305 let mut a = with_nul.with_raw_message(b"a\x00b");
1306 a.created_at = base.created_at;
1307
1308 let other = sample_state().with_change_id(base.change_id);
1309 let mut b = other.with_raw_message(b"a\x00c");
1310 b.created_at = base.created_at;
1311
1312 assert_ne!(a.hash(), b.hash());
1313 }
1314
1315 #[test]
1323 fn parse_extension_headers_preserves_noncanonical_wire_order() {
1324 let lines: &[&[u8]] = &[
1331 b"tree 1111111111111111111111111111111111111111",
1332 b"parent 2222222222222222222222222222222222222222",
1333 b"author Alice <alice@example.com> 1700000000 +0000",
1334 b"committer Bob <bob@example.com> 1700000100 +0000",
1335 b"x-custom custom value",
1336 b"gpgsig -----BEGIN PGP SIGNATURE-----",
1337 b" sig-line-1",
1338 b" -----END PGP SIGNATURE-----",
1339 b"encoding ISO-8859-1",
1340 b"mergetag object 3333333333333333333333333333333333333333",
1341 b" type commit",
1342 b" tag sidetag",
1343 b" tagger Carol <carol@example.com> 1700000050 +0000",
1344 b" ", b" signed side tag",
1346 b"", b"the commit message",
1348 b"",
1349 ];
1350 let content = lines.join(&b'\n');
1351
1352 let headers = parse_commit_extension_headers(&content);
1353
1354 let expected: Vec<(Vec<u8>, Vec<u8>)> = vec![
1355 (b"x-custom".to_vec(), b"custom value".to_vec()),
1356 (
1357 b"gpgsig".to_vec(),
1358 b"-----BEGIN PGP SIGNATURE-----\nsig-line-1\n-----END PGP SIGNATURE-----"
1361 .to_vec(),
1362 ),
1363 (b"encoding".to_vec(), b"ISO-8859-1".to_vec()),
1364 (
1365 b"mergetag".to_vec(),
1366 b"object 3333333333333333333333333333333333333333\ntype commit\ntag sidetag\ntagger Carol <carol@example.com> 1700000050 +0000\n\nsigned side tag".to_vec(),
1369 ),
1370 ];
1371
1372 assert_eq!(headers, expected);
1373 }
1374
1375 #[test]
1379 fn parse_extension_headers_empty_when_only_core_headers() {
1380 let content: &[u8] = b"\
1381tree 1111111111111111111111111111111111111111\n\
1382author Alice <alice@example.com> 1700000000 +0000\n\
1383committer Bob <bob@example.com> 1700000100 +0000\n\
1384\n\
1385just a message\n";
1386 assert!(parse_commit_extension_headers(content).is_empty());
1387 }
1388}