Skip to main content

lean_ctx_protocol/
knowledge.rs

1//! Knowledge provenance contract.
2
3use crate::common::{
4    ExtensionsV1, ValidationError, deserialize_schema_version, validate_schema_version,
5};
6use serde::de::Error as DeError;
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10/// Origin category for a knowledge object.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum KnowledgeSourceType {
14    Repository,
15    Documentation,
16    Provider,
17    UserInput,
18    BuildArtifact,
19    TestResult,
20    Other,
21}
22
23/// Authority level used when knowledge participates in a decision.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum KnowledgeAuthority {
27    Primary,
28    Verified,
29    Secondary,
30    Derived,
31    UserProvided,
32}
33
34/// Validity interval for a knowledge object.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(deny_unknown_fields)]
37pub struct KnowledgeValidityV1 {
38    pub valid_from: String,
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub valid_until: Option<String>,
41}
42
43/// A typed pointer to the system and revision that supplied a knowledge object.
44///
45/// `source_type` is serialized as `type` to keep the wire representation aligned
46/// with the public Knowledge Hub contract.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct SourceReference {
49    #[serde(rename = "type")]
50    pub source_type: String,
51    pub uri: String,
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub commit_sha: Option<String>,
54    pub access_timestamp: String,
55}
56
57/// Authority and review metadata supplied by a local knowledge producer.
58#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
59pub struct AuthorityMetadata {
60    pub owner: String,
61    pub confidence_level: f32,
62    pub review_status: String,
63}
64
65/// Validity and supersession metadata for a knowledge object.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct ValidityWindow {
68    pub valid_from: String,
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub valid_until: Option<String>,
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub superseded_by: Option<String>,
73}
74
75/// Classification level used by the portable Knowledge Hub surface.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
77pub enum ClassificationLevel {
78    Public,
79    Internal,
80    Confidential,
81    Restricted,
82}
83
84/// Classification and local retention metadata.
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86pub struct DataClassification {
87    pub level: ClassificationLevel,
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub retention_days: Option<u32>,
90}
91
92impl DataClassification {
93    fn from_legacy_value(value: &str) -> Result<Self, String> {
94        let level = match value.to_ascii_lowercase().as_str() {
95            "public" => ClassificationLevel::Public,
96            "internal" => ClassificationLevel::Internal,
97            "confidential" => ClassificationLevel::Confidential,
98            "restricted" => ClassificationLevel::Restricted,
99            other => return Err(format!("unknown data classification: {other}")),
100        };
101        Ok(Self {
102            level,
103            retention_days: None,
104        })
105    }
106}
107
108fn deserialize_optional_source_ref<'de, D>(
109    deserializer: D,
110) -> Result<Option<SourceReference>, D::Error>
111where
112    D: serde::Deserializer<'de>,
113{
114    let value = Option::<Value>::deserialize(deserializer)?;
115    match value {
116        None => Ok(None),
117        Some(Value::String(uri)) => Ok(Some(SourceReference {
118            source_type: "legacy".to_owned(),
119            uri,
120            commit_sha: None,
121            access_timestamp: String::new(),
122        })),
123        Some(value) => serde_json::from_value(value)
124            .map(Some)
125            .map_err(D::Error::custom),
126    }
127}
128
129fn deserialize_optional_authority<'de, D>(
130    deserializer: D,
131) -> Result<Option<AuthorityMetadata>, D::Error>
132where
133    D: serde::Deserializer<'de>,
134{
135    let value = Option::<Value>::deserialize(deserializer)?;
136    match value {
137        None => Ok(None),
138        Some(Value::String(review_status)) => Ok(Some(AuthorityMetadata {
139            owner: String::new(),
140            confidence_level: 0.0,
141            review_status,
142        })),
143        Some(value) => serde_json::from_value(value)
144            .map(Some)
145            .map_err(D::Error::custom),
146    }
147}
148
149fn deserialize_optional_classification<'de, D>(
150    deserializer: D,
151) -> Result<Option<DataClassification>, D::Error>
152where
153    D: serde::Deserializer<'de>,
154{
155    let value = Option::<Value>::deserialize(deserializer)?;
156    match value {
157        None => Ok(None),
158        Some(Value::String(level)) => DataClassification::from_legacy_value(&level)
159            .map(Some)
160            .map_err(D::Error::custom),
161        Some(value) => serde_json::from_value(value)
162            .map(Some)
163            .map_err(D::Error::custom),
164    }
165}
166
167/// Content-addressed knowledge provenance record. Unknown fields are retained.
168#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
169pub struct KnowledgeObjectV1 {
170    #[serde(deserialize_with = "deserialize_schema_version")]
171    pub schema_version: u32,
172    // The legacy V1 wire contract required the key; keep that presence
173    // invariant while allowing the richer value to be null.
174    #[serde(deserialize_with = "deserialize_optional_source_ref")]
175    pub source_ref: Option<SourceReference>,
176    pub source_type: KnowledgeSourceType,
177    #[serde(
178        default,
179        skip_serializing_if = "Option::is_none",
180        deserialize_with = "deserialize_optional_authority"
181    )]
182    pub authority: Option<AuthorityMetadata>,
183    pub owner: String,
184    #[serde(
185        default,
186        skip_serializing_if = "Option::is_none",
187        deserialize_with = "deserialize_optional_classification"
188    )]
189    pub classification: Option<DataClassification>,
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub validity: Option<ValidityWindow>,
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub supersedes: Option<String>,
194    pub content_hash: String,
195    pub evidence_digest: String,
196    pub policy_ref: String,
197    #[serde(default)]
198    pub evidence_refs: Vec<String>,
199    #[serde(default, flatten)]
200    pub extra: ExtensionsV1,
201}
202
203const KNOWLEDGE_RESERVED_FIELDS: &[&str] = &[
204    "schema_version",
205    "source_ref",
206    "source_type",
207    "authority",
208    "owner",
209    "classification",
210    "validity",
211    "supersedes",
212    "content_hash",
213    "evidence_digest",
214    "policy_ref",
215    "evidence_refs",
216];
217
218impl KnowledgeObjectV1 {
219    /// Validate schema invariants for a knowledge object.
220    pub fn validate(&self) -> Result<(), ValidationError> {
221        self.extra.validate_reserved(KNOWLEDGE_RESERVED_FIELDS)?;
222        validate_schema_version(self.schema_version)
223    }
224
225    /// Stable local-store identifier for this content-addressed object.
226    pub fn object_id(&self) -> &str {
227        &self.content_hash
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn serialization_round_trip() {
237        let knowledge = KnowledgeObjectV1 {
238            schema_version: 1,
239            source_ref: Some(SourceReference {
240                source_type: "documentation".to_owned(),
241                uri: "docs:architecture".to_owned(),
242                commit_sha: None,
243                access_timestamp: "2026-08-09T00:00:00Z".to_owned(),
244            }),
245            source_type: KnowledgeSourceType::Documentation,
246            authority: Some(AuthorityMetadata {
247                owner: "team-platform".to_owned(),
248                confidence_level: 0.95,
249                review_status: "verified".to_owned(),
250            }),
251            owner: "team-platform".to_owned(),
252            classification: Some(DataClassification {
253                level: ClassificationLevel::Internal,
254                retention_days: Some(365),
255            }),
256            validity: Some(ValidityWindow {
257                valid_from: "2026-08-09T00:00:00Z".to_owned(),
258                valid_until: None,
259                superseded_by: None,
260            }),
261            supersedes: Some("docs:architecture-old".to_owned()),
262            content_hash: "sha256:content".to_owned(),
263            evidence_digest: "sha256:evidence".to_owned(),
264            policy_ref: "policy:knowledge".to_owned(),
265            evidence_refs: vec!["evidence:architecture".to_owned()],
266            extra: {
267                let mut extra = ExtensionsV1::default();
268                extra
269                    .insert("extension", Value::from("kept"))
270                    .expect("extension should be valid");
271                extra
272            },
273        };
274        let json = serde_json::to_string(&knowledge).expect("knowledge should serialize");
275        let decoded: KnowledgeObjectV1 =
276            serde_json::from_str(&json).expect("knowledge should deserialize");
277        assert_eq!(knowledge, decoded);
278        knowledge
279            .validate()
280            .expect("knowledge should satisfy invariants");
281    }
282
283    #[test]
284    fn legacy_scalar_metadata_deserializes_into_optional_fields() {
285        let json = r#"{
286            "schema_version": 1,
287            "source_ref": "docs:architecture",
288            "source_type": "documentation",
289            "authority": "verified",
290            "owner": "team-platform",
291            "classification": "Internal",
292            "validity": {"valid_from": "2026-08-09T00:00:00Z"},
293            "content_hash": "sha256:content",
294            "evidence_digest": "sha256:evidence",
295            "policy_ref": "policy:knowledge"
296        }"#;
297
298        let knowledge: KnowledgeObjectV1 = serde_json::from_str(json).expect("legacy JSON parses");
299        assert_eq!(
300            knowledge
301                .source_ref
302                .as_ref()
303                .map(|source| source.uri.as_str()),
304            Some("docs:architecture")
305        );
306        assert_eq!(
307            knowledge
308                .authority
309                .as_ref()
310                .map(|authority| authority.review_status.as_str()),
311            Some("verified")
312        );
313        assert_eq!(
314            knowledge
315                .classification
316                .as_ref()
317                .map(|classification| classification.level),
318            Some(ClassificationLevel::Internal)
319        );
320        assert_eq!(knowledge.evidence_refs, Vec::<String>::new());
321    }
322}