1use crate::common::{ValidationError, deserialize_schema_version, validate_schema_version};
4use serde::de::Error as DeError;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use std::collections::BTreeMap;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum KnowledgeSourceType {
13 Repository,
14 Documentation,
15 Provider,
16 UserInput,
17 BuildArtifact,
18 TestResult,
19 Other,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum KnowledgeAuthority {
26 Primary,
27 Verified,
28 Secondary,
29 Derived,
30 UserProvided,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub struct KnowledgeValidityV1 {
37 pub valid_from: String,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub valid_until: Option<String>,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct SourceReference {
48 #[serde(rename = "type")]
49 pub source_type: String,
50 pub uri: String,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub commit_sha: Option<String>,
53 pub access_timestamp: String,
54}
55
56#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58pub struct AuthorityMetadata {
59 pub owner: String,
60 pub confidence_level: f32,
61 pub review_status: String,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct ValidityWindow {
67 pub valid_from: String,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub valid_until: Option<String>,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub superseded_by: Option<String>,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
76pub enum ClassificationLevel {
77 Public,
78 Internal,
79 Confidential,
80 Restricted,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85pub struct DataClassification {
86 pub level: ClassificationLevel,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub retention_days: Option<u32>,
89}
90
91impl DataClassification {
92 fn from_legacy_value(value: &str) -> Result<Self, String> {
93 let level = match value.to_ascii_lowercase().as_str() {
94 "public" => ClassificationLevel::Public,
95 "internal" => ClassificationLevel::Internal,
96 "confidential" => ClassificationLevel::Confidential,
97 "restricted" => ClassificationLevel::Restricted,
98 other => return Err(format!("unknown data classification: {other}")),
99 };
100 Ok(Self {
101 level,
102 retention_days: None,
103 })
104 }
105}
106
107fn deserialize_optional_source_ref<'de, D>(
108 deserializer: D,
109) -> Result<Option<SourceReference>, D::Error>
110where
111 D: serde::Deserializer<'de>,
112{
113 let value = Option::<Value>::deserialize(deserializer)?;
114 match value {
115 None => Ok(None),
116 Some(Value::String(uri)) => Ok(Some(SourceReference {
117 source_type: "legacy".to_owned(),
118 uri,
119 commit_sha: None,
120 access_timestamp: String::new(),
121 })),
122 Some(value) => serde_json::from_value(value)
123 .map(Some)
124 .map_err(D::Error::custom),
125 }
126}
127
128fn deserialize_optional_authority<'de, D>(
129 deserializer: D,
130) -> Result<Option<AuthorityMetadata>, D::Error>
131where
132 D: serde::Deserializer<'de>,
133{
134 let value = Option::<Value>::deserialize(deserializer)?;
135 match value {
136 None => Ok(None),
137 Some(Value::String(review_status)) => Ok(Some(AuthorityMetadata {
138 owner: String::new(),
139 confidence_level: 0.0,
140 review_status,
141 })),
142 Some(value) => serde_json::from_value(value)
143 .map(Some)
144 .map_err(D::Error::custom),
145 }
146}
147
148fn deserialize_optional_classification<'de, D>(
149 deserializer: D,
150) -> Result<Option<DataClassification>, D::Error>
151where
152 D: serde::Deserializer<'de>,
153{
154 let value = Option::<Value>::deserialize(deserializer)?;
155 match value {
156 None => Ok(None),
157 Some(Value::String(level)) => DataClassification::from_legacy_value(&level)
158 .map(Some)
159 .map_err(D::Error::custom),
160 Some(value) => serde_json::from_value(value)
161 .map(Some)
162 .map_err(D::Error::custom),
163 }
164}
165
166#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
168pub struct KnowledgeObjectV1 {
169 #[serde(deserialize_with = "deserialize_schema_version")]
170 pub schema_version: u32,
171 #[serde(deserialize_with = "deserialize_optional_source_ref")]
174 pub source_ref: Option<SourceReference>,
175 pub source_type: KnowledgeSourceType,
176 #[serde(
177 default,
178 skip_serializing_if = "Option::is_none",
179 deserialize_with = "deserialize_optional_authority"
180 )]
181 pub authority: Option<AuthorityMetadata>,
182 pub owner: String,
183 #[serde(
184 default,
185 skip_serializing_if = "Option::is_none",
186 deserialize_with = "deserialize_optional_classification"
187 )]
188 pub classification: Option<DataClassification>,
189 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub validity: Option<ValidityWindow>,
191 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub supersedes: Option<String>,
193 pub content_hash: String,
194 pub evidence_digest: String,
195 pub policy_ref: String,
196 #[serde(default)]
197 pub evidence_refs: Vec<String>,
198 #[serde(flatten)]
199 pub extra: BTreeMap<String, Value>,
200}
201
202impl KnowledgeObjectV1 {
203 pub fn validate(&self) -> Result<(), ValidationError> {
205 validate_schema_version(self.schema_version)
206 }
207
208 pub fn object_id(&self) -> &str {
210 &self.content_hash
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 #[test]
219 fn serialization_round_trip() {
220 let knowledge = KnowledgeObjectV1 {
221 schema_version: 1,
222 source_ref: Some(SourceReference {
223 source_type: "documentation".to_owned(),
224 uri: "docs:architecture".to_owned(),
225 commit_sha: None,
226 access_timestamp: "2026-08-09T00:00:00Z".to_owned(),
227 }),
228 source_type: KnowledgeSourceType::Documentation,
229 authority: Some(AuthorityMetadata {
230 owner: "team-platform".to_owned(),
231 confidence_level: 0.95,
232 review_status: "verified".to_owned(),
233 }),
234 owner: "team-platform".to_owned(),
235 classification: Some(DataClassification {
236 level: ClassificationLevel::Internal,
237 retention_days: Some(365),
238 }),
239 validity: Some(ValidityWindow {
240 valid_from: "2026-08-09T00:00:00Z".to_owned(),
241 valid_until: None,
242 superseded_by: None,
243 }),
244 supersedes: Some("docs:architecture-old".to_owned()),
245 content_hash: "sha256:content".to_owned(),
246 evidence_digest: "sha256:evidence".to_owned(),
247 policy_ref: "policy:knowledge".to_owned(),
248 evidence_refs: vec!["evidence:architecture".to_owned()],
249 extra: BTreeMap::from([("extension".to_owned(), Value::from("kept"))]),
250 };
251 let json = serde_json::to_string(&knowledge).expect("knowledge should serialize");
252 let decoded: KnowledgeObjectV1 =
253 serde_json::from_str(&json).expect("knowledge should deserialize");
254 assert_eq!(knowledge, decoded);
255 knowledge
256 .validate()
257 .expect("knowledge should satisfy invariants");
258 }
259
260 #[test]
261 fn legacy_scalar_metadata_deserializes_into_optional_fields() {
262 let json = r#"{
263 "schema_version": 1,
264 "source_ref": "docs:architecture",
265 "source_type": "documentation",
266 "authority": "verified",
267 "owner": "team-platform",
268 "classification": "Internal",
269 "validity": {"valid_from": "2026-08-09T00:00:00Z"},
270 "content_hash": "sha256:content",
271 "evidence_digest": "sha256:evidence",
272 "policy_ref": "policy:knowledge"
273 }"#;
274
275 let knowledge: KnowledgeObjectV1 = serde_json::from_str(json).expect("legacy JSON parses");
276 assert_eq!(
277 knowledge
278 .source_ref
279 .as_ref()
280 .map(|source| source.uri.as_str()),
281 Some("docs:architecture")
282 );
283 assert_eq!(
284 knowledge
285 .authority
286 .as_ref()
287 .map(|authority| authority.review_status.as_str()),
288 Some("verified")
289 );
290 assert_eq!(
291 knowledge
292 .classification
293 .as_ref()
294 .map(|classification| classification.level),
295 Some(ClassificationLevel::Internal)
296 );
297 assert_eq!(knowledge.evidence_refs, Vec::<String>::new());
298 }
299}