Skip to main content

objects/object/
state_core.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Core state type and its leaf value types (Status, StateSignature,
3//! SignatureStatus, Verification).
4
5use std::collections::BTreeMap;
6
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9
10use super::{Attribution, ChangeId, ContentHash, Principal, StateId};
11
12// ── Status ──────────────────────────────────────────────────────────
13
14/// Lifecycle status of a state.
15#[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// ── StateSignature ──────────────────────────────────────────────────
66
67/// Signature information for a state.
68#[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/// Signature verification result.
82#[derive(Clone, Copy, Debug, PartialEq, Eq)]
83pub enum SignatureStatus {
84    Valid,
85    Invalid,
86    Unsigned,
87}
88
89impl SignatureStatus {
90    pub fn is_valid(self) -> bool {
91        self == SignatureStatus::Valid
92    }
93
94    pub fn is_unsigned(self) -> bool {
95        self == SignatureStatus::Unsigned
96    }
97}
98
99// ── Verification ────────────────────────────────────────────────────
100
101/// Verification information for a state.
102#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
103pub struct Verification {
104    pub tests_passed: Option<bool>,
105    pub tests_failed: Option<u32>,
106    pub coverage_pct: Option<f32>,
107    pub coverage_delta: Option<f32>,
108    pub lint_warnings: Option<u32>,
109    #[serde(default)]
110    pub custom: BTreeMap<String, serde_json::Value>,
111}
112
113impl Verification {
114    pub fn new() -> Self {
115        Self::default()
116    }
117
118    pub fn with_tests_passed(mut self, passed: bool) -> Self {
119        self.tests_passed = Some(passed);
120        self
121    }
122
123    pub fn with_tests_failed(mut self, failed: u32) -> Self {
124        self.tests_failed = Some(failed);
125        self
126    }
127
128    pub fn is_empty(&self) -> bool {
129        self.tests_passed.is_none()
130            && self.tests_failed.is_none()
131            && self.coverage_pct.is_none()
132            && self.coverage_delta.is_none()
133            && self.lint_warnings.is_none()
134            && self.custom.is_empty()
135    }
136
137    pub(crate) fn hash_len(&self) -> usize {
138        let mut len = 0;
139        len += 1 + self.tests_passed.map(|_| 1).unwrap_or(0);
140        len += 1 + self.tests_failed.map(|_| 4).unwrap_or(0);
141        len += 1 + self.coverage_pct.map(|_| 4).unwrap_or(0);
142        len += 1 + self.coverage_delta.map(|_| 4).unwrap_or(0);
143        len += 1 + self.lint_warnings.map(|_| 4).unwrap_or(0);
144        len += 4;
145        for (key, value) in &self.custom {
146            let value_bytes = serde_json::to_vec(value).unwrap_or_default();
147            len += 4 + key.len();
148            len += 4 + value_bytes.len();
149        }
150        len
151    }
152
153    pub(crate) fn update_hasher(&self, hasher: &mut blake3::Hasher) {
154        let tests_passed = self.tests_passed.map(u8::from);
155        write_optional_u8(hasher, tests_passed);
156        write_optional_u32(hasher, self.tests_failed);
157        write_optional_f32(hasher, self.coverage_pct);
158        write_optional_f32(hasher, self.coverage_delta);
159        write_optional_u32(hasher, self.lint_warnings);
160        let custom_len = self.custom.len() as u32;
161        hasher.update(&custom_len.to_le_bytes());
162        for (key, value) in &self.custom {
163            let key_bytes = key.as_bytes();
164            let value_bytes = serde_json::to_vec(value).unwrap_or_default();
165            hasher.update(&(key_bytes.len() as u32).to_le_bytes());
166            hasher.update(key_bytes);
167            hasher.update(&(value_bytes.len() as u32).to_le_bytes());
168            hasher.update(&value_bytes);
169        }
170    }
171}
172
173fn write_optional_u8(hasher: &mut blake3::Hasher, value: Option<u8>) {
174    match value {
175        Some(v) => {
176            hasher.update(&[1]);
177            hasher.update(&[v]);
178        }
179        None => {
180            hasher.update(&[0]);
181        }
182    }
183}
184
185fn write_optional_u32(hasher: &mut blake3::Hasher, value: Option<u32>) {
186    match value {
187        Some(v) => {
188            hasher.update(&[1]);
189            hasher.update(&v.to_le_bytes());
190        }
191        None => {
192            hasher.update(&[0]);
193        }
194    }
195}
196
197fn write_optional_f32(hasher: &mut blake3::Hasher, value: Option<f32>) {
198    match value {
199        Some(v) => {
200            hasher.update(&[1]);
201            hasher.update(&v.to_le_bytes());
202        }
203        None => {
204            hasher.update(&[0]);
205        }
206    }
207}
208
209// ── State ───────────────────────────────────────────────────────────
210
211/// Immutable source-history state. `state_id` is recomputed from every encoded
212/// field; mutable repository metadata lives in `StateAttachment` objects.
213#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
214pub struct State {
215    #[serde(skip)]
216    pub state_id: StateId,
217    pub change_id: ChangeId,
218    pub tree: ContentHash,
219    pub parents: Vec<StateId>,
220    pub attribution: Attribution,
221    pub intent: Option<String>,
222    pub confidence: Option<f32>,
223    pub created_at: DateTime<Utc>,
224    pub verification: Option<Verification>,
225    pub status: Status,
226    // --- tail-only optional fields below. Add new fields here, never above. ---
227    #[serde(default)]
228    pub provenance: Option<ContentHash>,
229    /// Authoring timestamp for this state, when distinct from
230    /// `created_at`.
231    ///
232    /// `created_at` is the *committer* time — when the state object
233    /// came into being in its current form. `authored_at` is the
234    /// *author* time — when someone actually wrote the change — which
235    /// survives `git rebase`, cherry-pick, squash-merge, and `git
236    /// commit --amend`. The ingest-backed `import git` path fills
237    /// this from the git author time; native heddle commits leave it
238    /// `None` and blame falls back to `created_at`.
239    ///
240    /// **Part of the state hash (#564 de-lossy step 1).** Author time
241    /// is part of a git commit's identity: two commits that differ
242    /// *only* by author timestamp are distinct git objects, so folding
243    /// it into the hash keeps them from dedup-colliding to one State in
244    /// the content-addressed store. `None` hashes as a single absence
245    /// byte, so native commits are unaffected beyond the format bump.
246    #[serde(default)]
247    pub authored_at: Option<DateTime<Utc>>,
248    // --- git-fidelity fields (#564 de-lossy step 1, #565) ---
249    //
250    // These preserve the parts of an imported git commit that Heddle's
251    // model used to drop, so a commit can be byte-reconstructed later
252    // (#566/#567) and the git mirror can be eliminated (#568). UNLIKE the
253    // W1 tail fields above, these ARE part of the content hash (see
254    // `update_hash`): two git-distinct commits that differ only in
255    // committer, timezone, verbatim message, gpgsig, or extra headers must
256    // hash differently so they can't dedup-collide in the content-addressed
257    // store. They are still tail-append + `#[serde(default)]` so legacy
258    // on-disk states keep deserializing.
259    /// The git committer identity, when distinct from the author
260    /// ([`Attribution::principal`]). Git records both an author (who wrote
261    /// the change) and a committer (who created this commit object); for
262    /// rebased / cherry-picked / amended commits the two differ. `None`
263    /// for native heddle commits and for legacy imports from before #565.
264    #[serde(default)]
265    pub committer: Option<Principal>,
266    /// Timezone offset (seconds east of UTC) of the *author* timestamp
267    /// ([`State::authored_at`] / `created_at` fallback). Git stores the
268    /// author's local offset (e.g. `+0000`, `-0700`); Heddle used to
269    /// discard it. `0` for native commits and legacy imports.
270    #[serde(default)]
271    pub authored_tz_offset: i32,
272    /// Timezone offset (seconds east of UTC) of the *committer* timestamp
273    /// (`created_at`). `0` for native commits and legacy imports.
274    #[serde(default)]
275    pub committer_tz_offset: i32,
276    /// The verbatim git commit message body (everything after the header
277    /// block), preserved exactly so reconstruction is byte-stable. Distinct
278    /// from `intent`, which is the trimmed first line surfaced in the UI.
279    /// `None` for native commits and legacy imports.
280    ///
281    /// Stored as raw bytes, NOT a `String`: a commit with a non-UTF8
282    /// `encoding` (latin-1, shift-jis, …) carries message bytes that are not
283    /// valid UTF-8 (e.g. `0xe9` for latin-1 `é`); a `String` could not
284    /// round-trip them byte-identically. (non-UTF8 author/committer identity
285    /// *names* are not yet byte-preserved — `Principal` is still `String`; see
286    /// #564.)
287    #[serde(default)]
288    pub raw_message: Option<Vec<u8>>,
289    /// The SINGLE canonical "this state's content is NOT byte-faithful to the
290    /// original git object" marker (#567). Set to `true` by lossy import
291    /// population paths whenever an unrepresentable tree entry was dropped or
292    /// converted during import, so the rebuilt tree (hence commit) no longer
293    /// hashes to the original SHA. The git-export fidelity guard reads this one
294    /// flag to decide whether reconstruct-from-state is safe, instead of
295    /// enumerating import surfaces. `false` for native heddle commits and for
296    /// lossless imports.
297    ///
298    /// Provenance metadata, NOT part of the content hash: a lossy import always
299    /// drops/converts tree entries, so its tree — and therefore the rest of the
300    /// hashed identity — already differs from a lossless import of the same
301    /// source; folding the flag in would add nothing but break every existing
302    /// content hash.
303    #[serde(default)]
304    pub git_lossy: bool,
305    /// Every git commit header beyond the ones Heddle models natively
306    /// (tree/parents/author/committer), in their original order. ORDER IS
307    /// LOAD-BEARING for #566 byte-exactness — this is a `Vec`, never a map.
308    /// Empty for native commits and legacy imports.
309    ///
310    /// `gpgsig` is just one of these headers and is kept INLINE at its
311    /// captured ordinal (not split into a separate field): when a commit's
312    /// extension headers are in non-canonical order — e.g. `x-custom`, then
313    /// `gpgsig`, then `mergetag` — splitting gpgsig out would lose its
314    /// position and break byte-identical reconstruction. The serialization
315    /// source of truth for the signature is its position here (spike §3).
316    ///
317    /// Both the header name and value are raw bytes (`Vec<u8>`), NOT
318    /// `String`s: extra-header VALUES (a `mergetag` payload is a full tag
319    /// object; custom headers; gpgsig armor) can be non-UTF8, so a
320    /// `String` would force a lossy `to_string()` that destroys those bytes.
321    /// Names are ASCII by git's spec but are bytes too so the whole tuple is
322    /// byte-exact and no conversion sneaks in.
323    #[serde(default)]
324    pub extra_headers: Vec<(Vec<u8>, Vec<u8>)>,
325    pub lineage: Vec<ChangeLineage>,
326}
327
328impl State {
329    pub fn new(tree: ContentHash, parents: Vec<StateId>, attribution: Attribution) -> Self {
330        Self::new_snapshot(tree, parents, attribution)
331    }
332
333    pub fn new_snapshot(
334        tree: ContentHash,
335        parents: Vec<StateId>,
336        attribution: Attribution,
337    ) -> Self {
338        Self::new_with_change_id(tree, parents, attribution, ChangeId::generate())
339    }
340
341    pub fn new_merge(tree: ContentHash, parents: Vec<StateId>, attribution: Attribution) -> Self {
342        Self::new_snapshot(tree, parents, attribution)
343    }
344
345    pub fn new_refresh_of(
346        tree: ContentHash,
347        parents: Vec<StateId>,
348        attribution: Attribution,
349        change_id: ChangeId,
350    ) -> Self {
351        Self::new_with_change_id(tree, parents, attribution, change_id)
352    }
353
354    pub fn new_fork_of(tree: ContentHash, parents: Vec<StateId>, attribution: Attribution) -> Self {
355        Self::new_snapshot(tree, parents, attribution)
356    }
357
358    pub fn new_collapse_of(
359        tree: ContentHash,
360        parents: Vec<StateId>,
361        attribution: Attribution,
362    ) -> Self {
363        Self::new_snapshot(tree, parents, attribution)
364    }
365
366    fn new_with_change_id(
367        tree: ContentHash,
368        parents: Vec<StateId>,
369        attribution: Attribution,
370        change_id: ChangeId,
371    ) -> Self {
372        let mut state = Self {
373            state_id: StateId::default(),
374            change_id,
375            tree,
376            parents,
377            attribution,
378            intent: None,
379            confidence: None,
380            created_at: Utc::now(),
381            verification: None,
382            provenance: None,
383            authored_at: None,
384            committer: None,
385            authored_tz_offset: 0,
386            committer_tz_offset: 0,
387            raw_message: None,
388            git_lossy: false,
389            extra_headers: Vec::new(),
390            lineage: Vec::new(),
391            status: Status::Draft,
392        };
393        state.refresh_state_id();
394        state
395    }
396
397    pub fn with_intent(mut self, intent: impl Into<String>) -> Self {
398        self.intent = Some(intent.into());
399        self.refresh_state_id();
400        self
401    }
402
403    pub fn with_confidence(mut self, confidence: f32) -> Self {
404        self.confidence = Some(confidence.clamp(0.0, 1.0));
405        self.refresh_state_id();
406        self
407    }
408
409    pub fn with_verification(mut self, verification: Verification) -> Self {
410        self.verification = Some(verification);
411        self.refresh_state_id();
412        self
413    }
414
415    pub fn with_provenance(mut self, provenance: ContentHash) -> Self {
416        self.provenance = Some(provenance);
417        self.refresh_state_id();
418        self
419    }
420
421    /// Record the authoring timestamp separately from `created_at`.
422    /// Used by the git-ingest importer to preserve the distinction
423    /// between "when the change was originally written" (authored)
424    /// and "when this commit object came into being" (committer time,
425    /// stored in `created_at` so re-imports stay deterministic).
426    /// Native heddle commits leave this `None`; blame display then
427    /// falls back to `created_at`.
428    ///
429    /// **Part of the state hash (#564 de-lossy step 1)** — see the
430    /// `authored_at` field docs and `update_hash`.
431    pub fn with_authored_at(mut self, timestamp: DateTime<Utc>) -> Self {
432        self.authored_at = Some(timestamp);
433        self.refresh_state_id();
434        self
435    }
436
437    /// Record the git committer identity (distinct from the author).
438    ///
439    /// **Part of the state hash** — see the `committer` field docs and
440    /// `update_hash`. #564 de-lossy step 1.
441    pub fn with_committer(mut self, committer: Principal) -> Self {
442        self.committer = Some(committer);
443        self.refresh_state_id();
444        self
445    }
446
447    /// Record the author/committer timezone offsets (seconds east of UTC).
448    /// **Part of the state hash.** #564 de-lossy step 1.
449    pub fn with_tz_offsets(mut self, authored: i32, committer: i32) -> Self {
450        self.authored_tz_offset = authored;
451        self.committer_tz_offset = committer;
452        self.refresh_state_id();
453        self
454    }
455
456    /// Record the verbatim git commit message body, as raw bytes (so a
457    /// non-UTF8 message round-trips byte-identically; see the `raw_message`
458    /// field docs). **Part of the state hash.** #564 de-lossy step 1.
459    pub fn with_raw_message(mut self, raw_message: impl AsRef<[u8]>) -> Self {
460        self.raw_message = Some(raw_message.as_ref().to_vec());
461        self.refresh_state_id();
462        self
463    }
464
465    /// Mark this state's content as NOT byte-faithful to the original git
466    /// object — set by the `--lossy` import/ingest paths when a tree entry was
467    /// dropped or converted. The git-export fidelity guard reads this single
468    /// signal to skip reconstruct-from-state (#567). Not part of the content
469    /// hash (see the `git_lossy` field docs).
470    pub fn with_git_lossy(mut self, git_lossy: bool) -> Self {
471        self.git_lossy = git_lossy;
472        self.refresh_state_id();
473        self
474    }
475
476    /// Record the ordered remaining git commit headers as raw bytes. ORDER
477    /// IS LOAD-BEARING (#566). **Part of the state hash.** #564 de-lossy
478    /// step 1.
479    pub fn with_extra_headers(mut self, extra_headers: Vec<(Vec<u8>, Vec<u8>)>) -> Self {
480        self.extra_headers = extra_headers;
481        self.refresh_state_id();
482        self
483    }
484
485    pub fn with_lineage(mut self, lineage: Vec<ChangeLineage>) -> Self {
486        self.lineage = lineage;
487        self.refresh_state_id();
488        self
489    }
490
491    pub fn with_status(mut self, status: Status) -> Self {
492        self.status = status;
493        self.refresh_state_id();
494        self
495    }
496
497    pub fn with_change_id(mut self, change_id: ChangeId) -> Self {
498        self.change_id = change_id;
499        self.refresh_state_id();
500        self
501    }
502
503    pub fn with_timestamp(mut self, timestamp: DateTime<Utc>) -> Self {
504        self.created_at = timestamp;
505        self.refresh_state_id();
506        self
507    }
508
509    pub fn compute_hash(&self) -> ContentHash {
510        let content_len = self.hash_len();
511        ContentHash::compute_typed_with_len("state", content_len, |hasher| {
512            self.update_hash(hasher);
513        })
514    }
515
516    pub fn hash(&mut self) -> ContentHash {
517        self.refresh_state_id();
518        self.state_id.as_content_hash()
519    }
520
521    pub fn id(&self) -> StateId {
522        StateId::from_content_hash(self.compute_hash())
523    }
524
525    pub fn is_root(&self) -> bool {
526        self.parents.is_empty()
527    }
528
529    pub fn is_merge(&self) -> bool {
530        self.parents.len() > 1
531    }
532
533    pub fn is_agent_authored(&self) -> bool {
534        self.attribution.agent.is_some()
535    }
536
537    pub fn first_parent(&self) -> Option<&StateId> {
538        self.parents.first()
539    }
540
541    fn hash_len(&self) -> u64 {
542        self.hash_len_core() + self.hash_len_fidelity()
543    }
544
545    /// Hashed length of the core state fields. Mirrors [`Self::update_hash_core`].
546    fn hash_len_core(&self) -> u64 {
547        let principal = &self.attribution.principal;
548        let mut len = 0u64;
549
550        len += 16;
551
552        len += self.tree.as_bytes().len() as u64;
553        len += 4;
554        len += (self.parents.len() * 32) as u64;
555
556        len += principal.name.len() as u64 + 1;
557        len += principal.email.len() as u64 + 1;
558
559        len += 1;
560        if let Some(agent) = &self.attribution.agent {
561            len += agent.provider.len() as u64 + 1;
562            len += agent.model.len() as u64 + 1;
563
564            len += 1;
565            if let Some(session_id) = &agent.session_id {
566                len += session_id.len() as u64 + 1;
567            }
568
569            len += 1;
570            if let Some(policy_id) = &agent.policy_id {
571                len += policy_id.len() as u64 + 1;
572            }
573        }
574
575        len += 1;
576        if let Some(intent) = &self.intent {
577            len += intent.len() as u64 + 1;
578        }
579
580        len += 1;
581        if self.confidence.is_some() {
582            len += 4;
583        }
584
585        len += 8;
586
587        len += 1;
588        if let Some(verification) = &self.verification {
589            len += verification.hash_len() as u64;
590        }
591
592        len += 1;
593        if self.provenance.is_some() {
594            len += 32;
595        }
596
597        len += 1;
598
599        len
600    }
601
602    /// Hashed length of the appended git-fidelity block (#565). Mirrors
603    /// [`Self::update_hash_fidelity`] byte-for-byte. Kept separate from
604    /// [`Self::hash_len_core`] so the migration-only pre-bump hash can omit it
605    /// exactly.
606    fn hash_len_fidelity(&self) -> u64 {
607        let mut len = 0u64;
608
609        // git-fidelity fields (#564 step 1). Must mirror `update_hash`
610        // byte-for-byte. committer: 1 tag byte + (name+NUL, email+NUL).
611        len += 1;
612        if let Some(committer) = &self.committer {
613            len += committer.name.len() as u64 + 1;
614            len += committer.email.len() as u64 + 1;
615        }
616        // both tz offsets: i32 LE, always present.
617        len += 4;
618        len += 4;
619        // authored_at (author time): 1 tag byte + (i64 LE when Some).
620        len += 1;
621        if self.authored_at.is_some() {
622            len += 8;
623        }
624        // raw_message: optional-bytes framing (1 tag + u32 len + bytes) — a
625        // length prefix, not NUL-termination, since the message can contain
626        // NUL bytes (it's byte-typed for non-UTF8 fidelity).
627        len += 1;
628        if let Some(raw_message) = &self.raw_message {
629            len += 4 + raw_message.len() as u64;
630        }
631        // extra_headers (gpgsig rides inline here at its captured position):
632        // u32 count, then per pair u32 key_len+key, u32 val_len+val.
633        len += 4;
634        for (key, value) in &self.extra_headers {
635            len += 4 + key.len() as u64;
636            len += 4 + value.len() as u64;
637        }
638        len += 4 + (self.lineage.len() as u64 * 49);
639
640        len
641    }
642
643    fn update_hash(&self, hasher: &mut blake3::Hasher) {
644        self.update_hash_core(hasher);
645        self.update_hash_fidelity(hasher);
646    }
647
648    /// Hash the pre-#565 fields (everything through the status byte). Mirrors
649    /// [`Self::hash_len_core`]. The migration-only pre-bump hash is exactly
650    /// this with no fidelity block appended.
651    fn update_hash_core(&self, hasher: &mut blake3::Hasher) {
652        let principal = &self.attribution.principal;
653
654        hasher.update(self.change_id.as_bytes());
655
656        hasher.update(self.tree.as_bytes());
657        hasher.update(&(self.parents.len() as u32).to_le_bytes());
658        for parent in &self.parents {
659            hasher.update(parent.as_bytes());
660        }
661
662        hasher.update(principal.name.as_bytes());
663        hasher.update(&[0]);
664        hasher.update(principal.email.as_bytes());
665        hasher.update(&[0]);
666
667        if let Some(agent) = &self.attribution.agent {
668            hasher.update(&[1]);
669            hasher.update(agent.provider.as_bytes());
670            hasher.update(&[0]);
671            hasher.update(agent.model.as_bytes());
672            hasher.update(&[0]);
673            write_optional_string(hasher, &agent.session_id);
674            write_optional_string(hasher, &agent.segment_id);
675            write_optional_string(hasher, &agent.policy_id);
676        } else {
677            hasher.update(&[0]);
678        }
679
680        write_optional_string(hasher, &self.intent);
681
682        if let Some(confidence) = self.confidence {
683            hasher.update(&[1]);
684            hasher.update(&confidence.to_le_bytes());
685        } else {
686            hasher.update(&[0]);
687        }
688
689        hasher.update(&self.created_at.timestamp().to_le_bytes());
690
691        if let Some(verification) = &self.verification {
692            hasher.update(&[1]);
693            verification.update_hasher(hasher);
694        } else {
695            hasher.update(&[0]);
696        }
697
698        if let Some(provenance) = self.provenance {
699            hasher.update(&[1]);
700            hasher.update(provenance.as_bytes());
701        } else {
702            hasher.update(&[0]);
703        }
704
705        hasher.update(&[self.status.to_byte()]);
706    }
707
708    /// Hash the appended git-fidelity block (#565). Mirrors
709    /// [`Self::hash_len_fidelity`]. Kept separate from
710    /// [`Self::update_hash_core`] so the migration-only pre-bump hash can omit
711    /// it exactly.
712    ///
713    /// git-fidelity fields (#564 de-lossy step 1, #565) are DELIBERATELY part
714    /// of the content hash — the opposite of the W1 tail fields. Two git
715    /// commits that differ only in committer, author/committer time, timezone,
716    /// verbatim message, or extra headers (gpgsig included) are distinct git
717    /// objects; folding these into identity prevents them from dedup-colliding
718    /// to one State in the content-addressed store. This re-hashes every
719    /// pre-#565 state (a real format bump; acceptable pre-0.3). Keep this in
720    /// sync with `hash_len_fidelity`.
721    fn update_hash_fidelity(&self, hasher: &mut blake3::Hasher) {
722        if let Some(committer) = &self.committer {
723            hasher.update(&[1]);
724            hasher.update(committer.name.as_bytes());
725            hasher.update(&[0]);
726            hasher.update(committer.email.as_bytes());
727            hasher.update(&[0]);
728        } else {
729            hasher.update(&[0]);
730        }
731
732        hasher.update(&self.authored_tz_offset.to_le_bytes());
733        hasher.update(&self.committer_tz_offset.to_le_bytes());
734
735        // Author time (#564): committer time is hashed above as created_at;
736        // author time is the other half of a git commit's temporal identity.
737        if let Some(authored_at) = self.authored_at {
738            hasher.update(&[1]);
739            hasher.update(&authored_at.timestamp().to_le_bytes());
740        } else {
741            hasher.update(&[0]);
742        }
743
744        write_optional_bytes(hasher, &self.raw_message);
745
746        // extra_headers (gpgsig is one of these, kept inline at its position).
747        hasher.update(&(self.extra_headers.len() as u32).to_le_bytes());
748        for (key, value) in &self.extra_headers {
749            hasher.update(&(key.len() as u32).to_le_bytes());
750            hasher.update(key);
751            hasher.update(&(value.len() as u32).to_le_bytes());
752            hasher.update(value);
753        }
754        hasher.update(&(self.lineage.len() as u32).to_le_bytes());
755        for lineage in &self.lineage {
756            hasher.update(&[lineage.kind.to_byte()]);
757            hasher.update(lineage.source_change.as_bytes());
758            hasher.update(lineage.source_state.as_bytes());
759        }
760    }
761
762    fn refresh_state_id(&mut self) {
763        self.state_id = StateId::from_content_hash(self.compute_hash());
764    }
765}
766
767/// Length-prefixed optional-bytes framing for the hash: `[1] + u32-LE len +
768/// bytes` when `Some`, a single `[0]` when `None`. Unlike
769/// [`write_optional_string`]'s NUL-terminated framing this is binary-safe —
770/// `raw_message` can contain NUL bytes, so a length prefix (not a terminator)
771/// is required to keep the hash unambiguous.
772fn write_optional_bytes(hasher: &mut blake3::Hasher, value: &Option<Vec<u8>>) {
773    match value {
774        Some(bytes) => {
775            hasher.update(&[1]);
776            hasher.update(&(bytes.len() as u32).to_le_bytes());
777            hasher.update(bytes);
778        }
779        None => {
780            hasher.update(&[0]);
781        }
782    }
783}
784
785fn write_optional_string(hasher: &mut blake3::Hasher, value: &Option<String>) {
786    match value {
787        Some(value) => {
788            hasher.update(&[1]);
789            hasher.update(value.as_bytes());
790            hasher.update(&[0]);
791        }
792        None => {
793            hasher.update(&[0]);
794        }
795    }
796}
797
798/// Parse the *extension* headers from a raw git commit object's content bytes
799/// (the bytes `git cat-file commit <sha>` prints — i.e. gix's `Commit::data`),
800/// in their exact on-the-wire order, ready to store in [`State::extra_headers`].
801///
802/// A commit's header block runs from the start of the content up to the first
803/// blank line (the header/body separator). Its leading headers are always, in
804/// fixed order, `tree`, zero-or-more `parent`, `author`, `committer`; Heddle
805/// models those natively. Every header **after** `committer` is an extension
806/// header (`encoding`, `gpgsig`, `mergetag`, or any unknown/future name) and is
807/// returned here as a `(name, value)` byte pair at its real position.
808///
809/// **This is the single source of truth for extension-header order and bytes.**
810/// Both git import paths (the CLI bridge and the ingest walker) build
811/// `extra_headers` from it. The alternative — stitching the vec back together
812/// from a decoder's *typed* accessors (gix surfaces `encoding`, and historically
813/// `gpgsig`, as fields *outside* its `extra_headers`) — silently reorders the
814/// headers git happens to model as typed fields, which breaks #566 byte-exact
815/// reconstruction. So we never consult those typed accessors for position; the
816/// raw header block is authoritative. (#564 de-lossy step 1 — close-the-class.)
817///
818/// Folded continuation lines (a value line beginning with a single space
819/// `0x20`, used by `gpgsig`/`mergetag`) are **unfolded**: each continuation
820/// contributes a `\n` plus the line with exactly one leading space stripped, so
821/// the stored value holds the value's real internal newlines with no trailing
822/// newline. The serializer (#566) re-folds by mapping every `\n` back to `\n `
823/// (spike §2). A "blank" line inside an armored value is ` \n` on the wire (one
824/// space), so it unfolds to an empty segment — never confused with the
825/// header/body separator, which is a truly empty line.
826pub fn parse_commit_extension_headers(commit_content: &[u8]) -> Vec<(Vec<u8>, Vec<u8>)> {
827    // The header block ends at the first *empty* line. Folded "blank" lines
828    // inside an armored value are ` \n` (a single space), never empty, so the
829    // first `\n\n` reliably marks the header/body boundary.
830    let header_block = match find_subslice(commit_content, b"\n\n") {
831        Some(idx) => &commit_content[..idx],
832        // No separator (malformed / header-only) — treat all of it as headers.
833        None => commit_content,
834    };
835
836    // Collect every logical header (name, unfolded value) in order; the
837    // extension headers are the ones after the `committer` line.
838    let mut headers: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
839    for line in header_block.split(|&b| b == b'\n') {
840        if line.first() == Some(&b' ') {
841            // Continuation of the current header value: restore the newline
842            // that folding replaced and strip exactly one leading space.
843            if let Some((_, value)) = headers.last_mut() {
844                value.push(b'\n');
845                value.extend_from_slice(&line[1..]);
846            }
847            // A continuation with no preceding header is malformed git; skip it
848            // rather than panic.
849            continue;
850        }
851        // New header: `name<SP>value`. A header line with no space is degenerate
852        // (git never emits one in this region) — record it with an empty value
853        // so no bytes are silently dropped.
854        let (name, value) = match line.iter().position(|&b| b == b' ') {
855            Some(sp) => (line[..sp].to_vec(), line[sp + 1..].to_vec()),
856            None => (line.to_vec(), Vec::new()),
857        };
858        headers.push((name, value));
859    }
860
861    // Extension headers are everything strictly after `committer`. git always
862    // emits exactly one committer line ahead of the extension headers; if it is
863    // somehow absent, fall back to excluding the four core names so nothing is
864    // silently dropped or mis-captured.
865    match headers.iter().position(|(name, _)| name == b"committer") {
866        Some(idx) => headers.split_off(idx + 1),
867        None => headers
868            .into_iter()
869            .filter(|(name, _)| {
870                !matches!(
871                    name.as_slice(),
872                    b"tree" | b"parent" | b"author" | b"committer"
873                )
874            })
875            .collect(),
876    }
877}
878
879/// Index of the first occurrence of `needle` in `haystack`, or `None`.
880fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
881    if needle.is_empty() || needle.len() > haystack.len() {
882        return None;
883    }
884    haystack.windows(needle.len()).position(|w| w == needle)
885}
886
887#[cfg(test)]
888mod tests {
889    use super::*;
890    use crate::object::Principal;
891
892    fn sample_attribution() -> Attribution {
893        Attribution::human(Principal::new("Alice", "alice@example.com"))
894    }
895
896    #[test]
897    fn new_snapshot_sets_fresh_logical_identity() {
898        let state =
899            State::new_snapshot(ContentHash::compute(b"tree"), vec![], sample_attribution());
900        assert!(!state.change_id.is_zero());
901        assert_eq!(state.state_id, state.id());
902    }
903
904    #[test]
905    fn new_refresh_preserves_explicit_logical_identity() {
906        let logical_change_id = ChangeId::from_bytes([7; 16]);
907        let state = State::new_refresh_of(
908            ContentHash::compute(b"tree"),
909            vec![],
910            sample_attribution(),
911            logical_change_id,
912        );
913        assert_eq!(state.change_id, logical_change_id);
914    }
915
916    #[test]
917    fn new_merge_uses_fresh_logical_identity() {
918        let state = State::new_merge(
919            ContentHash::compute(b"tree"),
920            vec![StateId::from_bytes([1; 32]), StateId::from_bytes([2; 32])],
921            sample_attribution(),
922        );
923        assert!(!state.change_id.is_zero());
924        assert!(state.is_merge());
925    }
926
927    #[test]
928    fn with_change_id_invalidates_cached_hash_when_logical_identity_changes() {
929        let mut state =
930            State::new_snapshot(ContentHash::compute(b"tree"), vec![], sample_attribution());
931        let original_hash = state.hash();
932        let replacement = ChangeId::from_bytes([9; 16]);
933
934        let mut updated = state.with_change_id(replacement);
935
936        assert_eq!(updated.change_id, replacement);
937        assert_ne!(updated.hash(), original_hash);
938        assert_eq!(updated.hash(), updated.compute_hash());
939    }
940
941    #[test]
942    fn agent_segment_is_part_of_state_hash() {
943        let principal = Principal::new("Alice", "alice@example.com");
944        let attribution_a = Attribution::with_agent(
945            principal.clone(),
946            crate::object::Agent::new("openai", "gpt-5").with_session("sess-1", "seg-1"),
947        );
948        let attribution_b = Attribution::with_agent(
949            principal,
950            crate::object::Agent::new("openai", "gpt-5").with_session("sess-1", "seg-2"),
951        );
952        let tree = ContentHash::compute(b"tree");
953        let timestamp = Utc::now();
954        let logical_change_id = ChangeId::from_bytes([3; 16]);
955        let state_a = State::new_snapshot(tree, vec![], attribution_a)
956            .with_change_id(logical_change_id)
957            .with_timestamp(timestamp);
958        let state_b = State::new_snapshot(tree, vec![], attribution_b)
959            .with_change_id(logical_change_id)
960            .with_timestamp(timestamp);
961
962        assert_ne!(state_a.compute_hash(), state_b.compute_hash());
963    }
964
965    fn sample_state() -> State {
966        State::new_snapshot(ContentHash::compute(b"tree"), vec![], sample_attribution())
967    }
968
969    fn assert_mutator_invalidates_cached_hash(
970        mut state: State,
971        mutate: impl FnOnce(State) -> State,
972    ) {
973        let original_hash = state.hash();
974        let mut updated = mutate(state);
975        assert_ne!(updated.hash(), original_hash);
976        assert_eq!(updated.hash(), updated.compute_hash());
977    }
978
979    #[test]
980    fn with_intent_invalidates_cached_hash() {
981        assert_mutator_invalidates_cached_hash(sample_state(), |state| {
982            state.with_intent("capture intent")
983        });
984    }
985
986    #[test]
987    fn with_confidence_invalidates_cached_hash() {
988        assert_mutator_invalidates_cached_hash(sample_state(), |state| state.with_confidence(0.9));
989    }
990
991    #[test]
992    fn with_verification_invalidates_cached_hash() {
993        assert_mutator_invalidates_cached_hash(sample_state(), |state| {
994            state.with_verification(Verification::new().with_tests_passed(true))
995        });
996    }
997
998    #[test]
999    fn with_status_invalidates_cached_hash() {
1000        assert_mutator_invalidates_cached_hash(sample_state(), |state| {
1001            state.with_status(Status::Published)
1002        });
1003    }
1004
1005    #[test]
1006    fn with_timestamp_invalidates_cached_hash() {
1007        assert_mutator_invalidates_cached_hash(sample_state(), |state| {
1008            state.with_timestamp(Utc::now() + chrono::Duration::seconds(1))
1009        });
1010    }
1011
1012    /// The git-fidelity fields (#564 step 1) MUST be part of the hash so two
1013    /// git-distinct commits can't dedup-collide. Each field, set in
1014    /// isolation, must move the hash.
1015    #[test]
1016    fn fidelity_fields_are_part_of_state_hash() {
1017        let base = sample_state();
1018        let base_hash = base.compute_hash();
1019
1020        let with_committer = sample_state().with_change_id(base.change_id);
1021        let mut with_committer =
1022            with_committer.with_committer(Principal::new("Carol", "carol@example.com"));
1023        with_committer.created_at = base.created_at;
1024        assert_ne!(
1025            with_committer.hash(),
1026            base_hash,
1027            "committer must affect the state hash"
1028        );
1029
1030        for mutate in [
1031            |s: State| s.with_tz_offsets(3600, -7200),
1032            |s: State| s.with_authored_at(Utc::now() + chrono::Duration::seconds(1)),
1033            |s: State| s.with_raw_message("verbatim body\n"),
1034            // gpgsig now rides inline in extra_headers at its captured position.
1035            |s: State| {
1036                s.with_extra_headers(vec![(
1037                    b"gpgsig".to_vec(),
1038                    b"-----BEGIN PGP SIGNATURE-----\n".to_vec(),
1039                )])
1040            },
1041            |s: State| s.with_extra_headers(vec![(b"mergetag".to_vec(), b"x".to_vec())]),
1042        ] {
1043            let seeded = sample_state().with_change_id(base.change_id);
1044            let mut decorated = mutate(seeded);
1045            decorated.created_at = base.created_at;
1046            assert_ne!(
1047                decorated.hash(),
1048                base_hash,
1049                "fidelity field must affect the state hash"
1050            );
1051        }
1052    }
1053
1054    /// extra_headers order is load-bearing (#566): the same pairs in a
1055    /// different order must hash differently.
1056    #[test]
1057    fn extra_headers_order_affects_hash() {
1058        let base = sample_state();
1059        let one = sample_state().with_change_id(base.change_id);
1060        let mut one = one.with_extra_headers(vec![
1061            (b"a".to_vec(), b"1".to_vec()),
1062            (b"b".to_vec(), b"2".to_vec()),
1063        ]);
1064        one.created_at = base.created_at;
1065
1066        let two = sample_state().with_change_id(base.change_id);
1067        let mut two = two.with_extra_headers(vec![
1068            (b"b".to_vec(), b"2".to_vec()),
1069            (b"a".to_vec(), b"1".to_vec()),
1070        ]);
1071        two.created_at = base.created_at;
1072
1073        assert_ne!(one.hash(), two.hash());
1074    }
1075
1076    /// The fidelity fields set together produce a stable, recomputable
1077    /// hash (guards against a `hash_len`/`update_hash` divergence making
1078    /// the cached hash differ from a fresh `compute_hash`).
1079    #[test]
1080    fn fidelity_fields_hash_is_stable() {
1081        let mut state = sample_state()
1082            .with_committer(Principal::new("Dave", "dave@example.com"))
1083            .with_tz_offsets(3600, 0)
1084            .with_authored_at(Utc::now())
1085            .with_raw_message("body\n")
1086            .with_extra_headers(vec![
1087                (b"gpgsig".to_vec(), b"sig".to_vec()),
1088                (b"k".to_vec(), b"v".to_vec()),
1089            ]);
1090        assert_eq!(state.hash(), state.compute_hash());
1091    }
1092
1093    /// A non-UTF8 git message body (latin-1 `café` = `caf\xe9`) must be
1094    /// stored byte-identically. `raw_message` is `Vec<u8>`, not `String`,
1095    /// precisely so these bytes survive; the hash stays stable/recomputable
1096    /// over the raw bytes (length-prefixed framing, NUL-safe). #564 step 1.
1097    #[test]
1098    fn non_utf8_raw_message_is_byte_preserved() {
1099        let raw = b"caf\xe9\n".to_vec();
1100        assert!(
1101            String::from_utf8(raw.clone()).is_err(),
1102            "test fixture must be invalid UTF-8 to be meaningful"
1103        );
1104        let mut state = sample_state().with_raw_message(&raw);
1105        assert_eq!(
1106            state.raw_message.as_deref(),
1107            Some(raw.as_slice()),
1108            "raw bytes preserved verbatim"
1109        );
1110        // rmp serialize → deserialize (the store's on-disk codec) keeps the
1111        // bytes intact, and the hash recomputes identically afterwards.
1112        let bytes = rmp_serde::to_vec(&state).expect("serialize state");
1113        let back: State = rmp_serde::from_slice(&bytes).expect("deserialize state");
1114        assert_eq!(back.raw_message.as_deref(), Some(raw.as_slice()));
1115        let mut back = back;
1116        assert_eq!(state.hash(), back.hash());
1117        assert_eq!(back.hash(), back.compute_hash());
1118    }
1119
1120    /// A NUL byte inside the message must not be swallowed/truncated by the
1121    /// hash framing — length-prefixed `raw_message` is what makes this safe,
1122    /// where the old NUL-terminated string framing would have been ambiguous.
1123    #[test]
1124    fn raw_message_with_nul_byte_changes_hash() {
1125        let base = sample_state();
1126        let with_nul = sample_state().with_change_id(base.change_id);
1127        let mut a = with_nul.with_raw_message(b"a\x00b");
1128        a.created_at = base.created_at;
1129
1130        let other = sample_state().with_change_id(base.change_id);
1131        let mut b = other.with_raw_message(b"a\x00c");
1132        b.created_at = base.created_at;
1133
1134        assert_ne!(a.hash(), b.hash());
1135    }
1136
1137    /// Close-the-class conformance: extension headers are captured from the
1138    /// raw commit header block in their EXACT on-the-wire order, regardless of
1139    /// which ones a decoder would surface as typed fields. A commit whose
1140    /// optional headers are in non-canonical order — `x-custom`, then a folded
1141    /// `gpgsig`, then `encoding`, then a folded `mergetag` — must reproduce that
1142    /// exact ordered `(name, value)` byte sequence. This fails if any header is
1143    /// reordered, prepended, appended, or dropped. #564 de-lossy step 1.
1144    #[test]
1145    fn parse_extension_headers_preserves_noncanonical_wire_order() {
1146        // A folded `mergetag` value carries a full tag object, which itself has
1147        // an internal blank line between the tag headers and the tag message —
1148        // on the wire that blank line is folded to a single space (` `), NEVER
1149        // an empty line, so it must not be mistaken for the header/body split.
1150        // Built line-by-line (NOT a `\`-continued literal, which would eat the
1151        // load-bearing leading space on each folded continuation line).
1152        let lines: &[&[u8]] = &[
1153            b"tree 1111111111111111111111111111111111111111",
1154            b"parent 2222222222222222222222222222222222222222",
1155            b"author Alice <alice@example.com> 1700000000 +0000",
1156            b"committer Bob <bob@example.com> 1700000100 +0000",
1157            b"x-custom custom value",
1158            b"gpgsig -----BEGIN PGP SIGNATURE-----",
1159            b" sig-line-1",
1160            b" -----END PGP SIGNATURE-----",
1161            b"encoding ISO-8859-1",
1162            b"mergetag object 3333333333333333333333333333333333333333",
1163            b" type commit",
1164            b" tag sidetag",
1165            b" tagger Carol <carol@example.com> 1700000050 +0000",
1166            b" ", // folded blank line inside the tag object (one space)
1167            b" signed side tag",
1168            b"", // the real header/body separator (empty line)
1169            b"the commit message",
1170            b"",
1171        ];
1172        let content = lines.join(&b'\n');
1173
1174        let headers = parse_commit_extension_headers(&content);
1175
1176        let expected: Vec<(Vec<u8>, Vec<u8>)> = vec![
1177            (b"x-custom".to_vec(), b"custom value".to_vec()),
1178            (
1179                b"gpgsig".to_vec(),
1180                // Unfolded: internal newlines restored, NO trailing newline (the
1181                // serializer re-folds each `\n` to `\n `, spike §2).
1182                b"-----BEGIN PGP SIGNATURE-----\nsig-line-1\n-----END PGP SIGNATURE-----"
1183                    .to_vec(),
1184            ),
1185            (b"encoding".to_vec(), b"ISO-8859-1".to_vec()),
1186            (
1187                b"mergetag".to_vec(),
1188                // The folded ` \n` blank line unfolds to an empty segment, so the
1189                // tag object's header/message split survives as a real `\n\n`.
1190                b"object 3333333333333333333333333333333333333333\ntype commit\ntag sidetag\ntagger Carol <carol@example.com> 1700000050 +0000\n\nsigned side tag".to_vec(),
1191            ),
1192        ];
1193
1194        assert_eq!(headers, expected);
1195    }
1196
1197    /// A commit with no extension headers (the common case) yields an empty
1198    /// vec — `tree`/`parent`/`author`/`committer` are modelled natively and
1199    /// never leak into `extra_headers`.
1200    #[test]
1201    fn parse_extension_headers_empty_when_only_core_headers() {
1202        let content: &[u8] = b"\
1203tree 1111111111111111111111111111111111111111\n\
1204author Alice <alice@example.com> 1700000000 +0000\n\
1205committer Bob <bob@example.com> 1700000100 +0000\n\
1206\n\
1207just a message\n";
1208        assert!(parse_commit_extension_headers(content).is_empty());
1209    }
1210}