Skip to main content

heddle_object_model/object/
state_context.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Context annotations for files, symbols, line ranges, and broader state guidance.
3
4use std::path::{Component, Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7
8use crate::object::{
9    hash::{ContentHash, StateId},
10    visibility_tier::VisibilityTier,
11};
12
13const FILE_TARGET_ROOT: &str = "__files";
14const STATE_TARGET_ROOT: &str = "__states";
15
16/// A collection of logical annotations for a single target.
17#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
18pub struct ContextBlob {
19    pub format_version: u8,
20    pub annotations: Vec<Annotation>,
21}
22
23/// A stable logical annotation with revision history.
24#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
25pub struct Annotation {
26    pub annotation_id: String,
27    pub scope: AnnotationScope,
28    pub status: AnnotationStatus,
29    pub revisions: Vec<AnnotationRevision>,
30    #[serde(default)]
31    pub supersedes_annotation_id: Option<String>,
32    #[serde(default)]
33    pub supersedes_rewrite_pct: Option<u32>,
34    // --- tail-only optional fields below; new fields go here. ---
35    /// Visibility scope. Pre-W1 annotations have no field on disk; rmp-serde
36    /// fills the default ([`VisibilityTier::Public`]), preserving the
37    /// pre-existing meaning ("annotations are publicly visible").
38    #[serde(default)]
39    pub visibility: VisibilityTier,
40    /// Back-pointer set when this annotation was produced by resolving a
41    /// discussion. Lets viewers jump from the annotation back to the
42    /// discussion that produced it.
43    #[serde(default)]
44    pub resolved_from_discussion: Option<String>,
45    /// Materialized health of the annotation's file anchor. Lifecycle status
46    /// remains separate: an active annotation can need anchor attention.
47    #[serde(default)]
48    pub anchor_status: AnnotationAnchorStatus,
49}
50
51/// A single revision of a logical annotation.
52#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
53pub struct AnnotationRevision {
54    pub revision_id: String,
55    pub kind: AnnotationKind,
56    pub content: String,
57    pub tags: Vec<String>,
58    pub attribution: String,
59    pub created_at: i64,
60    /// BLAKE3 hash of the source bytes at the annotated scope when created.
61    /// For File scope: hash of entire file blob.
62    /// For Symbol/Lines: hash of the relevant byte range.
63    #[serde(default)]
64    pub source_hash: Option<ContentHash>,
65    /// The State this revision was created against.
66    /// Enables retrieving the exact source as it was at annotation time.
67    #[serde(default)]
68    pub created_at_state: Option<StateId>,
69}
70
71#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
72pub enum AnnotationStatus {
73    Active,
74    Superseded,
75}
76
77/// Snapshot-time resolution state for a file-backed context annotation.
78#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
79pub enum AnnotationAnchorStatus {
80    /// The target path currently resolves, including after a confident move.
81    #[default]
82    Resolved,
83    /// More than one path passed the rename confidence threshold.
84    Ambiguous { candidate_paths: Vec<String> },
85    /// The target disappeared and no path passed the rename threshold.
86    Orphaned,
87}
88
89/// The canonical annotation taxonomy the product surfaces.
90///
91/// `Constraint`, `Invariant`, and `Rationale` are the three kinds of
92/// reasoning we keep alongside code. The lowercase serde names are the
93/// wire/storage vocabulary shared with proto and the web API.
94#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(rename_all = "lowercase")]
96pub enum AnnotationKind {
97    /// A rule the code must obey. Example: "empty scope must return NoScope".
98    Constraint,
99    /// A property that must hold across operations. Example: "state DAG is append-only".
100    Invariant,
101    /// Design decision + reasoning. Example: "thread resolution walks to LCA because…".
102    Rationale,
103}
104
105/// A typed target for context entries.
106#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
107pub enum ContextTarget {
108    File { path: String },
109    State { state_id: StateId },
110}
111
112/// What part of a file an annotation targets.
113#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
114pub enum AnnotationScope {
115    File,
116    Symbol {
117        name: String,
118        /// Line range resolved at annotation creation time via tree-sitter.
119        /// Enables the web UI to show exact code for this symbol.
120        #[serde(default, skip_serializing_if = "Option::is_none")]
121        resolved_lines: Option<(u32, u32)>,
122    },
123    Lines(u32, u32),
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
127pub enum ContextError {
128    #[error("unsupported context format version {0}")]
129    UnsupportedVersion(u8),
130    #[error("line range start {0} exceeds end {1}")]
131    InvalidLineRange(u32, u32),
132    #[error("symbol name must not be empty")]
133    EmptySymbol,
134    #[error("file target path must not be empty")]
135    EmptyTargetPath,
136    #[error("context target path must be relative, got: {0}")]
137    AbsoluteTargetPath(String),
138    #[error("invalid context target path: {0}")]
139    InvalidTargetPath(String),
140    #[error("state-level guidance must use file scope only")]
141    StateTargetMustUseFileScope,
142    #[error("annotation {0} has no revisions")]
143    MissingRevisions(String),
144    #[error("invalid context encoding: {0}")]
145    InvalidEncoding(String),
146}
147
148// Current encoded format version is 2. Reject anything that isn't the
149// current value — no live deployments to migrate from.
150versioned_msgpack_blob! {
151    blob: ContextBlob,
152    item: Annotation,
153    field: annotations,
154    error: ContextError,
155    codec_err: InvalidEncoding,
156    version: 2,
157}
158
159impl Annotation {
160    #[allow(clippy::too_many_arguments)]
161    pub fn new(
162        scope: AnnotationScope,
163        kind: AnnotationKind,
164        content: String,
165        tags: Vec<String>,
166        attribution: String,
167        created_at: i64,
168        source_hash: Option<ContentHash>,
169        created_at_state: Option<StateId>,
170    ) -> Self {
171        Self {
172            annotation_id: uuid::Uuid::now_v7().to_string(),
173            scope,
174            status: AnnotationStatus::Active,
175            revisions: vec![AnnotationRevision {
176                revision_id: uuid::Uuid::now_v7().to_string(),
177                kind,
178                content,
179                tags,
180                attribution,
181                created_at,
182                source_hash,
183                created_at_state,
184            }],
185            supersedes_annotation_id: None,
186            supersedes_rewrite_pct: None,
187            visibility: VisibilityTier::default(),
188            resolved_from_discussion: None,
189            anchor_status: AnnotationAnchorStatus::default(),
190        }
191    }
192
193    pub fn current_revision(&self) -> Option<&AnnotationRevision> {
194        self.revisions.last()
195    }
196
197    pub fn current_revision_mut(&mut self) -> Option<&mut AnnotationRevision> {
198        self.revisions.last_mut()
199    }
200
201    #[allow(clippy::too_many_arguments)]
202    pub fn revise(
203        &mut self,
204        kind: AnnotationKind,
205        content: String,
206        tags: Vec<String>,
207        attribution: String,
208        created_at: i64,
209        source_hash: Option<ContentHash>,
210        created_at_state: Option<StateId>,
211    ) -> &AnnotationRevision {
212        self.revisions.push(AnnotationRevision {
213            revision_id: uuid::Uuid::now_v7().to_string(),
214            kind,
215            content,
216            tags,
217            attribution,
218            created_at,
219            source_hash,
220            created_at_state,
221        });
222        self.current_revision().expect("new revision appended")
223    }
224
225    pub fn mark_superseded(&mut self) {
226        self.status = AnnotationStatus::Superseded;
227    }
228
229    pub fn validate(&self) -> Result<(), ContextError> {
230        self.scope.validate()?;
231        if self.annotation_id.is_empty() {
232            return Err(ContextError::InvalidEncoding(
233                "annotation_id must not be empty".to_string(),
234            ));
235        }
236        if self.revisions.is_empty() {
237            return Err(ContextError::MissingRevisions(self.annotation_id.clone()));
238        }
239        for revision in &self.revisions {
240            revision.validate()?;
241        }
242        Ok(())
243    }
244}
245
246impl AnnotationRevision {
247    pub fn validate(&self) -> Result<(), ContextError> {
248        if self.revision_id.is_empty() {
249            return Err(ContextError::InvalidEncoding(
250                "revision_id must not be empty".to_string(),
251            ));
252        }
253        Ok(())
254    }
255}
256
257impl AnnotationKind {
258    pub fn as_str(&self) -> &'static str {
259        match self {
260            Self::Constraint => "constraint",
261            Self::Invariant => "invariant",
262            Self::Rationale => "rationale",
263        }
264    }
265}
266
267impl std::fmt::Display for AnnotationKind {
268    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
269        write!(f, "{}", self.as_str())
270    }
271}
272
273impl std::str::FromStr for AnnotationKind {
274    type Err = ContextError;
275
276    fn from_str(value: &str) -> Result<Self, Self::Err> {
277        match value {
278            "constraint" => Ok(Self::Constraint),
279            "invariant" => Ok(Self::Invariant),
280            "rationale" => Ok(Self::Rationale),
281            _ => Err(ContextError::InvalidEncoding(format!(
282                "invalid annotation kind '{value}'"
283            ))),
284        }
285    }
286}
287
288impl ContextTarget {
289    /// Construct a file-scope target. The path must be non-empty,
290    /// relative, and walkable — it's stored inside the context tree
291    /// under `__files/<path>`, and the downstream writer's
292    /// `split_path` helper only understands `Component::Normal` (no
293    /// `RootDir`, no `ParentDir`, no `CurDir`-only trails).
294    ///
295    /// Previously this accepted any non-empty string, which meant
296    /// absolute paths like `/Users/me/repo/src/auth.rs` got all the
297    /// way to `Repository::set_context_blob` before failing with a
298    /// cryptic `"empty path"` error deep in the tree-insert routine.
299    /// Rejecting here turns that into a clear
300    /// `AbsoluteTargetPath`/`InvalidTargetPath` at the callsite.
301    pub fn file(path: impl Into<String>) -> Result<Self, ContextError> {
302        let path = path.into();
303        if path.trim().is_empty() {
304            return Err(ContextError::EmptyTargetPath);
305        }
306        let p = Path::new(&path);
307        if p.is_absolute() {
308            return Err(ContextError::AbsoluteTargetPath(path));
309        }
310        // Walk components: reject `..` anywhere (would let the path
311        // escape `__files/`), and require at least one `Normal`
312        // component (rejects paths like `.`, `./.`, or strings whose
313        // every component is `CurDir`).
314        let mut saw_normal = false;
315        for component in p.components() {
316            match component {
317                Component::Normal(_) => saw_normal = true,
318                Component::CurDir => {}
319                Component::ParentDir => {
320                    return Err(ContextError::InvalidTargetPath(path));
321                }
322                Component::RootDir | Component::Prefix(_) => {
323                    // `is_absolute` above already catches the typical
324                    // cases on both Unix and Windows, but belt-and-
325                    // braces: if a Prefix or RootDir sneaks through
326                    // on some platform, still reject.
327                    return Err(ContextError::AbsoluteTargetPath(path));
328                }
329            }
330        }
331        if !saw_normal {
332            return Err(ContextError::InvalidTargetPath(path));
333        }
334        Ok(Self::File { path })
335    }
336
337    pub fn state(state_id: StateId) -> Self {
338        Self::State { state_id }
339    }
340
341    pub fn validate_scope(&self, scope: &AnnotationScope) -> Result<(), ContextError> {
342        match self {
343            Self::File { .. } => scope.validate(),
344            Self::State { .. } => {
345                if matches!(scope, AnnotationScope::File) {
346                    Ok(())
347                } else {
348                    Err(ContextError::StateTargetMustUseFileScope)
349                }
350            }
351        }
352    }
353
354    pub fn storage_path(&self) -> PathBuf {
355        match self {
356            Self::File { path } => Path::new(FILE_TARGET_ROOT).join(path),
357            Self::State { state_id } => {
358                Path::new(STATE_TARGET_ROOT).join(state_id.to_string_full())
359            }
360        }
361    }
362
363    pub fn from_storage_path(path: &Path) -> Option<Self> {
364        let mut components = path.components();
365        match components.next()? {
366            Component::Normal(part) if part == FILE_TARGET_ROOT => {
367                let rest = components.as_path();
368                if rest.as_os_str().is_empty() {
369                    None
370                } else {
371                    Some(Self::File {
372                        path: rest.to_string_lossy().to_string(),
373                    })
374                }
375            }
376            Component::Normal(part) if part == STATE_TARGET_ROOT => {
377                let rest = components.as_path();
378                let mut state_components = rest.components();
379                let Component::Normal(id) = state_components.next()? else {
380                    return None;
381                };
382                if !state_components.as_path().as_os_str().is_empty() {
383                    return None;
384                }
385                StateId::parse(&id.to_string_lossy())
386                    .ok()
387                    .map(|state_id| Self::State { state_id })
388            }
389            _ => None,
390        }
391    }
392
393    pub fn path(&self) -> Option<&str> {
394        match self {
395            Self::File { path } => Some(path),
396            Self::State { .. } => None,
397        }
398    }
399
400    pub fn state_id(&self) -> Option<StateId> {
401        match self {
402            Self::State { state_id } => Some(*state_id),
403            Self::File { .. } => None,
404        }
405    }
406}
407
408impl AnnotationScope {
409    pub fn validate(&self) -> Result<(), ContextError> {
410        match self {
411            Self::File => Ok(()),
412            Self::Symbol {
413                name,
414                resolved_lines,
415            } => {
416                if name.is_empty() {
417                    return Err(ContextError::EmptySymbol);
418                }
419                if let Some((start, end)) = resolved_lines
420                    && start > end
421                {
422                    return Err(ContextError::InvalidLineRange(*start, *end));
423                }
424                Ok(())
425            }
426            Self::Lines(start, end) => {
427                if start > end {
428                    Err(ContextError::InvalidLineRange(*start, *end))
429                } else {
430                    Ok(())
431                }
432            }
433        }
434    }
435
436    pub fn matches(&self, other: &Self) -> bool {
437        match (self, other) {
438            (Self::File, Self::File) => true,
439            (Self::Symbol { name: a, .. }, Self::Symbol { name: b, .. }) => a == b,
440            (Self::Lines(a1, a2), Self::Lines(b1, b2)) => a1 == b1 && a2 == b2,
441            _ => false,
442        }
443    }
444
445    pub fn symbol_name(&self) -> Option<&str> {
446        match self {
447            Self::Symbol { name, .. } => Some(name),
448            _ => None,
449        }
450    }
451
452    pub fn line_range(&self) -> Option<(u32, u32)> {
453        match self {
454            Self::Lines(start, end) => Some((*start, *end)),
455            Self::Symbol {
456                resolved_lines: Some((start, end)),
457                ..
458            } => Some((*start, *end)),
459            _ => None,
460        }
461    }
462}
463
464impl std::fmt::Display for AnnotationScope {
465    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
466        match self {
467            Self::File => write!(f, "file"),
468            Self::Symbol { name, .. } => write!(f, "symbol:{name}"),
469            Self::Lines(start, end) => write!(f, "lines:{start}-{end}"),
470        }
471    }
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477
478    // --- ContextTarget::file validation --------------------------------
479
480    #[test]
481    fn context_target_accepts_relative_paths() {
482        // Plain relative, nested, and dotfile forms should all pass.
483        assert!(ContextTarget::file("src/auth.rs").is_ok());
484        assert!(ContextTarget::file("a/b/c.txt").is_ok());
485        assert!(ContextTarget::file(".gitignore").is_ok());
486        assert!(ContextTarget::file("a").is_ok());
487        // A leading `./` is pure noise; still accepted (the CurDir
488        // components are ignored, and `a` is a Normal component).
489        assert!(ContextTarget::file("./a").is_ok());
490    }
491
492    #[test]
493    fn context_target_rejects_empty_path() {
494        assert!(matches!(
495            ContextTarget::file(""),
496            Err(ContextError::EmptyTargetPath)
497        ));
498        assert!(matches!(
499            ContextTarget::file("   "),
500            Err(ContextError::EmptyTargetPath)
501        ));
502    }
503
504    #[test]
505    fn context_target_rejects_absolute_path_unix() {
506        let err = ContextTarget::file("/Users/me/repo/src/auth.rs").unwrap_err();
507        assert!(
508            matches!(err, ContextError::AbsoluteTargetPath(ref p) if p == "/Users/me/repo/src/auth.rs"),
509            "got {err:?}"
510        );
511        // Root alone also absolute.
512        assert!(matches!(
513            ContextTarget::file("/"),
514            Err(ContextError::AbsoluteTargetPath(_))
515        ));
516    }
517
518    #[test]
519    fn context_target_rejects_parent_escape() {
520        // `..` anywhere would let a writer escape `__files/` inside
521        // the context tree.
522        assert!(matches!(
523            ContextTarget::file("../etc/passwd"),
524            Err(ContextError::InvalidTargetPath(_))
525        ));
526        assert!(matches!(
527            ContextTarget::file("src/../../escape"),
528            Err(ContextError::InvalidTargetPath(_))
529        ));
530    }
531
532    #[test]
533    fn context_target_rejects_all_dot_components() {
534        // A path made entirely of `.`/`./.` is non-empty under the
535        // old check but has no Normal component to write under, so
536        // downstream writes would fail cryptically. Catch it here.
537        assert!(matches!(
538            ContextTarget::file("."),
539            Err(ContextError::InvalidTargetPath(_))
540        ));
541        assert!(matches!(
542            ContextTarget::file("./."),
543            Err(ContextError::InvalidTargetPath(_))
544        ));
545    }
546
547    #[test]
548    fn roundtrips_revision_with_missing_source_hash_and_present_state() {
549        let created_at_state = StateId::from_bytes([3; 32]);
550        let blob = ContextBlob::new(vec![Annotation::new(
551            AnnotationScope::File,
552            AnnotationKind::Rationale,
553            "Entry point".to_string(),
554            vec!["critical".to_string()],
555            "test@example.com".to_string(),
556            1700000000,
557            None,
558            Some(created_at_state),
559        )]);
560
561        let encoded = blob.encode().unwrap();
562        let decoded = ContextBlob::decode(&encoded).unwrap();
563        let revision = decoded.annotations[0].current_revision().unwrap();
564        assert_eq!(revision.source_hash, None);
565        assert_eq!(revision.created_at_state, Some(created_at_state));
566    }
567
568    #[test]
569    fn roundtrip_serialization() {
570        let blob = ContextBlob::new(vec![Annotation::new(
571            AnnotationScope::File,
572            AnnotationKind::Invariant,
573            "Entry point".to_string(),
574            vec!["constraint".to_string()],
575            "test@example.com".to_string(),
576            1700000000,
577            None,
578            None,
579        )]);
580
581        let bytes = blob.encode().unwrap();
582        let decoded = ContextBlob::decode(&bytes).unwrap();
583        assert_eq!(blob, decoded);
584    }
585
586    #[test]
587    fn legacy_annotation_without_anchor_status_decodes_as_resolved() {
588        #[derive(Serialize)]
589        struct LegacyAnnotation {
590            annotation_id: String,
591            scope: AnnotationScope,
592            status: AnnotationStatus,
593            revisions: Vec<AnnotationRevision>,
594            supersedes_annotation_id: Option<String>,
595            supersedes_rewrite_pct: Option<u32>,
596            visibility: VisibilityTier,
597            resolved_from_discussion: Option<String>,
598        }
599
600        #[derive(Serialize)]
601        struct LegacyContextBlob {
602            format_version: u8,
603            annotations: Vec<LegacyAnnotation>,
604        }
605
606        let revision = AnnotationRevision {
607            revision_id: "legacy-revision".to_string(),
608            kind: AnnotationKind::Invariant,
609            content: "legacy context".to_string(),
610            tags: vec![],
611            attribution: "test@example.com".to_string(),
612            created_at: 1_700_000_000,
613            source_hash: None,
614            created_at_state: None,
615        };
616        let bytes = rmp_serde::to_vec(&LegacyContextBlob {
617            format_version: ContextBlob::FORMAT_VERSION,
618            annotations: vec![LegacyAnnotation {
619                annotation_id: "legacy-annotation".to_string(),
620                scope: AnnotationScope::File,
621                status: AnnotationStatus::Active,
622                revisions: vec![revision],
623                supersedes_annotation_id: None,
624                supersedes_rewrite_pct: None,
625                visibility: VisibilityTier::default(),
626                resolved_from_discussion: None,
627            }],
628        })
629        .unwrap();
630
631        let decoded = ContextBlob::decode(&bytes).unwrap();
632        assert_eq!(
633            decoded.annotations[0].anchor_status,
634            AnnotationAnchorStatus::Resolved
635        );
636    }
637
638    #[test]
639    fn validate_good_blob() {
640        let blob = ContextBlob::new(vec![]);
641        blob.validate().unwrap();
642    }
643
644    #[test]
645    fn validate_bad_version() {
646        let blob = ContextBlob {
647            format_version: 99,
648            annotations: vec![],
649        };
650        assert!(matches!(
651            blob.validate(),
652            Err(ContextError::UnsupportedVersion(99))
653        ));
654    }
655
656    #[test]
657    fn validate_bad_line_range() {
658        let blob = ContextBlob::new(vec![Annotation::new(
659            AnnotationScope::Lines(20, 10),
660            AnnotationKind::Rationale,
661            "bad".to_string(),
662            vec![],
663            "test".to_string(),
664            0,
665            None,
666            None,
667        )]);
668        assert!(matches!(
669            blob.validate(),
670            Err(ContextError::InvalidLineRange(20, 10))
671        ));
672    }
673
674    #[test]
675    fn validate_empty_symbol() {
676        let blob = ContextBlob::new(vec![Annotation::new(
677            AnnotationScope::Symbol {
678                name: String::new(),
679                resolved_lines: None,
680            },
681            AnnotationKind::Rationale,
682            "bad".to_string(),
683            vec![],
684            "test".to_string(),
685            0,
686            None,
687            None,
688        )]);
689        assert!(matches!(blob.validate(), Err(ContextError::EmptySymbol)));
690    }
691
692    #[test]
693    fn scope_matching() {
694        assert!(AnnotationScope::File.matches(&AnnotationScope::File));
695        assert!(
696            AnnotationScope::Symbol {
697                name: "foo".into(),
698                resolved_lines: None
699            }
700            .matches(&AnnotationScope::Symbol {
701                name: "foo".into(),
702                resolved_lines: Some((1, 5))
703            })
704        );
705        assert!(
706            !AnnotationScope::Symbol {
707                name: "foo".into(),
708                resolved_lines: None
709            }
710            .matches(&AnnotationScope::Symbol {
711                name: "bar".into(),
712                resolved_lines: None
713            })
714        );
715        assert!(AnnotationScope::Lines(1, 10).matches(&AnnotationScope::Lines(1, 10)));
716    }
717
718    #[test]
719    fn state_targets_only_allow_file_scope() {
720        let target = ContextTarget::state(StateId::from_bytes([1; 32]));
721        assert!(target.validate_scope(&AnnotationScope::File).is_ok());
722        assert!(matches!(
723            target.validate_scope(&AnnotationScope::Lines(1, 2)),
724            Err(ContextError::StateTargetMustUseFileScope)
725        ));
726    }
727
728    #[test]
729    fn context_target_storage_roundtrip() {
730        let file = ContextTarget::file("src/main.rs").unwrap();
731        assert_eq!(
732            ContextTarget::from_storage_path(&file.storage_path()),
733            Some(file.clone())
734        );
735
736        let state = ContextTarget::state(StateId::from_bytes([2; 32]));
737        assert_eq!(
738            ContextTarget::from_storage_path(&state.storage_path()),
739            Some(state)
740        );
741    }
742
743    #[test]
744    fn context_target_storage_rejects_legacy_direct_paths() {
745        assert_eq!(
746            ContextTarget::from_storage_path(Path::new("src/main.rs")),
747            None
748        );
749    }
750}