Skip to main content

chronicle/schema/
v2.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4use super::common::{AstAnchor, LineRange};
5
6// ---------------------------------------------------------------------------
7// Top-level Annotation
8// ---------------------------------------------------------------------------
9
10#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
11pub struct Annotation {
12    pub schema: String,
13    pub commit: String,
14    pub timestamp: String,
15
16    /// The narrative (commit-level, always present).
17    pub narrative: Narrative,
18
19    /// Design decisions (zero or more).
20    #[serde(default, skip_serializing_if = "Vec::is_empty")]
21    pub decisions: Vec<Decision>,
22
23    /// Code-level markers (optional, only where valuable).
24    #[serde(default, skip_serializing_if = "Vec::is_empty")]
25    pub markers: Vec<CodeMarker>,
26
27    /// Link to broader effort.
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub effort: Option<EffortLink>,
30
31    /// How this annotation was created.
32    pub provenance: Provenance,
33}
34
35impl Annotation {
36    /// Validate the annotation for structural correctness.
37    pub fn validate(&self) -> Result<(), String> {
38        if self.schema != "chronicle/v2" {
39            return Err(format!("unsupported schema version: {}", self.schema));
40        }
41        if self.commit.is_empty() {
42            return Err("commit SHA is empty".to_string());
43        }
44        if self.narrative.summary.is_empty() {
45            return Err("narrative summary is empty".to_string());
46        }
47        for (i, marker) in self.markers.iter().enumerate() {
48            if let Err(e) = marker.validate() {
49                return Err(format!("marker[{}]: {}", i, e));
50            }
51        }
52        Ok(())
53    }
54}
55
56// ---------------------------------------------------------------------------
57// Narrative
58// ---------------------------------------------------------------------------
59
60#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
61pub struct Narrative {
62    /// What this commit does and WHY this approach. Not a diff restatement.
63    pub summary: String,
64
65    /// What triggered this change? User request, bug, planned work?
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub motivation: Option<String>,
68
69    /// What alternatives were considered and rejected.
70    #[serde(default, skip_serializing_if = "Vec::is_empty")]
71    pub rejected_alternatives: Vec<RejectedAlternative>,
72
73    /// Expected follow-up. None = this is complete.
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub follow_up: Option<String>,
76
77    /// Files touched (auto-populated from diff for indexing).
78    #[serde(default, skip_serializing_if = "Vec::is_empty")]
79    pub files_changed: Vec<String>,
80
81    /// Agent intuitions: worries, hunches, confidence, unease.
82    #[serde(default, skip_serializing_if = "Vec::is_empty")]
83    pub sentiments: Vec<Sentiment>,
84}
85
86/// An agent sentiment — an intuition about the work that isn't captured
87/// by facts alone. Feelings resist rigid categorization, so `feeling` is
88/// a free string.
89#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
90pub struct Sentiment {
91    /// e.g. "worry", "confidence", "uncertainty", "pride", "unease",
92    /// "curiosity", "frustration", "surprise", "doubt"
93    pub feeling: String,
94    /// What specifically and why.
95    pub detail: String,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
99pub struct RejectedAlternative {
100    pub approach: String,
101    pub reason: String,
102}
103
104// ---------------------------------------------------------------------------
105// Decisions
106// ---------------------------------------------------------------------------
107
108#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
109pub struct Decision {
110    /// What was decided.
111    pub what: String,
112    /// Why.
113    pub why: String,
114    /// How stable is this decision.
115    pub stability: Stability,
116    /// When to revisit.
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub revisit_when: Option<String>,
119    /// Files/modules this applies to.
120    #[serde(default, skip_serializing_if = "Vec::is_empty")]
121    pub scope: Vec<String>,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
125#[serde(rename_all = "snake_case")]
126pub enum Stability {
127    Permanent,
128    Provisional,
129    Experimental,
130}
131
132// ---------------------------------------------------------------------------
133// Code Markers (replaces RegionAnnotation)
134// ---------------------------------------------------------------------------
135
136#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
137pub struct CodeMarker {
138    pub file: String,
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub anchor: Option<AstAnchor>,
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub lines: Option<LineRange>,
143    pub kind: MarkerKind,
144}
145
146impl CodeMarker {
147    pub fn validate(&self) -> Result<(), String> {
148        if self.file.is_empty() {
149            return Err("file is empty".to_string());
150        }
151        if let Some(lines) = &self.lines {
152            if lines.start > lines.end {
153                return Err(format!(
154                    "invalid line range: start ({}) > end ({})",
155                    lines.start, lines.end
156                ));
157            }
158        }
159        Ok(())
160    }
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
164#[serde(rename_all = "snake_case", tag = "type")]
165pub enum MarkerKind {
166    /// Behavioral contract: invariant, precondition, assumption.
167    Contract {
168        description: String,
169        source: ContractSource,
170    },
171    /// Something non-obvious that could cause bugs.
172    Hazard { description: String },
173    /// This code assumes something about code elsewhere.
174    Dependency {
175        target_file: String,
176        target_anchor: String,
177        assumption: String,
178    },
179    /// This code is provisional/experimental.
180    Unstable {
181        description: String,
182        revisit_when: String,
183    },
184    /// Security-sensitive code: auth, crypto, input validation, etc.
185    Security { description: String },
186    /// Performance-sensitive code: hot paths, allocations, latency.
187    Performance { description: String },
188    /// Deprecated code with optional replacement pointer.
189    Deprecated {
190        description: String,
191        #[serde(skip_serializing_if = "Option::is_none")]
192        replacement: Option<String>,
193    },
194    /// Known technical debt to address later.
195    TechDebt { description: String },
196    /// Test coverage note: what's tested, what's missing.
197    TestCoverage { description: String },
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
201#[serde(rename_all = "snake_case")]
202pub enum ContractSource {
203    Author,
204    Inferred,
205}
206
207// ---------------------------------------------------------------------------
208// Effort Linking
209// ---------------------------------------------------------------------------
210
211#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
212pub struct EffortLink {
213    /// Stable identifier (ticket ID, slug, etc.)
214    pub id: String,
215    pub description: String,
216    pub phase: EffortPhase,
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
220#[serde(rename_all = "snake_case")]
221pub enum EffortPhase {
222    Start,
223    InProgress,
224    Complete,
225}
226
227// ---------------------------------------------------------------------------
228// Provenance
229// ---------------------------------------------------------------------------
230
231#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
232pub struct Provenance {
233    pub source: ProvenanceSource,
234    #[serde(skip_serializing_if = "Option::is_none")]
235    pub author: Option<String>,
236    #[serde(default, skip_serializing_if = "Vec::is_empty")]
237    pub derived_from: Vec<String>,
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub notes: Option<String>,
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
243#[serde(rename_all = "snake_case")]
244pub enum ProvenanceSource {
245    Live,
246    Batch,
247    Backfill,
248    Squash,
249    Amend,
250    MigratedV1,
251    MigratedV2,
252}
253
254impl std::fmt::Display for ProvenanceSource {
255    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256        match self {
257            Self::Live => write!(f, "live"),
258            Self::Batch => write!(f, "batch"),
259            Self::Backfill => write!(f, "backfill"),
260            Self::Squash => write!(f, "squash"),
261            Self::Amend => write!(f, "amend"),
262            Self::MigratedV1 => write!(f, "migrated_v1"),
263            Self::MigratedV2 => write!(f, "migrated_v2"),
264        }
265    }
266}