Skip to main content

lean_ctx_protocol/
knowledge.rs

1//! Knowledge provenance contract.
2
3use 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/// Origin category for a knowledge object.
10#[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/// Authority level used when knowledge participates in a decision.
23#[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/// Validity interval for a knowledge object.
34#[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/// A typed pointer to the system and revision that supplied a knowledge object.
43///
44/// `source_type` is serialized as `type` to keep the wire representation aligned
45/// with the public Knowledge Hub contract.
46#[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/// Authority and review metadata supplied by a local knowledge producer.
57#[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/// Validity and supersession metadata for a knowledge object.
65#[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/// Classification level used by the portable Knowledge Hub surface.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
76pub enum ClassificationLevel {
77    Public,
78    Internal,
79    Confidential,
80    Restricted,
81}
82
83/// Classification and local retention metadata.
84#[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/// Content-addressed knowledge provenance record. Unknown fields are retained.
167#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
168pub struct KnowledgeObjectV1 {
169    #[serde(deserialize_with = "deserialize_schema_version")]
170    pub schema_version: u32,
171    // The legacy V1 wire contract required the key; keep that presence
172    // invariant while allowing the richer value to be null.
173    #[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    /// Validate schema invariants for a knowledge object.
204    pub fn validate(&self) -> Result<(), ValidationError> {
205        validate_schema_version(self.schema_version)
206    }
207
208    /// Stable local-store identifier for this content-addressed object.
209    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}