Skip to main content

proofborne_core/
context.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use thiserror::Error;
5use uuid::Uuid;
6
7use crate::{AssuranceLevel, SCHEMA_VERSION, hash_bytes};
8
9/// Semantic category of one provenance-bound context statement.
10#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
11#[serde(rename_all = "snake_case")]
12pub enum ContextKind {
13    /// Runtime-observed tool, process, Git, or completion evidence.
14    Observation,
15    /// Unverified model-authored conclusion.
16    Assertion,
17    /// Explicit human or runtime policy decision.
18    Decision,
19    /// User-authored durable preference.
20    UserPreference,
21    /// Statement imported from an external source.
22    External,
23}
24
25/// Provenance anchor for one context statement.
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27#[serde(rename_all = "camelCase")]
28pub struct ContextSource {
29    /// Session whose causal event introduced this statement.
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub session_id: Option<Uuid>,
32    /// Hash of the source event in the session event chain.
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub event_id: Option<String>,
35    /// Source event sequence in the session event chain.
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub event_seq: Option<u64>,
38    /// Runtime evidence represented by this context item.
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub evidence_id: Option<Uuid>,
41    /// Runtime generation observed by the source.
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub workspace_generation: Option<u64>,
44    /// Canonical workspace digest observed by the source.
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub state_binding: Option<String>,
47    /// Optional external provenance URI.
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub external_uri: Option<String>,
50}
51
52/// Scope in which a context statement may be retrieved.
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
54#[serde(rename_all = "camelCase")]
55pub struct ContextScope {
56    /// Secret-free digest of the canonical workspace identity.
57    pub workspace_id: String,
58    /// Optional repository identity supplied by a trusted runtime integration.
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub repository: Option<String>,
61    /// Optional branch identity supplied by a trusted runtime integration.
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub branch: Option<String>,
64    /// Workspace-relative path, when the statement is path-scoped.
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub path: Option<String>,
67    /// Language symbol, when the statement is symbol-scoped.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub symbol: Option<String>,
70    /// Task contract that caused the observation.
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub task_contract_id: Option<Uuid>,
73}
74
75/// Authority that produced a context statement.
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
77#[serde(rename_all = "camelCase")]
78pub struct ContextAuthority {
79    /// Stable producer identity.
80    pub producer: String,
81    /// Assurance boundary inherited from the source observation.
82    pub assurance_level: AssuranceLevel,
83    /// Whether the producer and classification are runtime-owned.
84    pub runtime_owned: bool,
85}
86
87/// Public indication that sensitive input was removed before persistence.
88#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
89#[serde(rename_all = "snake_case")]
90pub enum SecretTaint {
91    /// No configured secret matched the persisted public statement.
92    #[default]
93    None,
94    /// Sensitive material matched and only a redacted statement was retained.
95    Redacted,
96}
97
98/// Immutable, content-addressed context statement.
99#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
100#[serde(rename_all = "camelCase")]
101pub struct ContextItem {
102    /// Public schema identifier.
103    pub schema_version: String,
104    /// Stable item identifier.
105    pub id: Uuid,
106    /// Statement category.
107    pub kind: ContextKind,
108    /// Public, redacted statement body.
109    pub content: String,
110    /// BLAKE3 digest of the exact UTF-8 statement body.
111    pub content_digest: String,
112    /// Causal provenance.
113    pub source: ContextSource,
114    /// Retrieval boundary.
115    pub scope: ContextScope,
116    /// Source observation time.
117    pub observed_at: DateTime<Utc>,
118    /// Producer authority.
119    pub authority: ContextAuthority,
120    /// Sensitive-data handling result.
121    #[serde(default)]
122    pub secret_taint: SecretTaint,
123    /// Public structured attributes; never used as verification evidence by itself.
124    #[serde(default)]
125    pub attributes: Value,
126}
127
128impl ContextItem {
129    /// Creates one runtime-owned observation from a redacted evidence statement.
130    #[must_use]
131    pub fn runtime_observation(
132        content: impl Into<String>,
133        source: ContextSource,
134        scope: ContextScope,
135        observed_at: DateTime<Utc>,
136        authority: ContextAuthority,
137        secret_taint: SecretTaint,
138        attributes: Value,
139    ) -> Self {
140        let content = content.into();
141        Self {
142            schema_version: SCHEMA_VERSION.to_owned(),
143            id: Uuid::now_v7(),
144            kind: ContextKind::Observation,
145            content_digest: hash_bytes(content.as_bytes()),
146            content,
147            source,
148            scope,
149            observed_at,
150            authority,
151            secret_taint,
152            attributes,
153        }
154    }
155
156    /// Validates public shape, digest, provenance, and scope invariants.
157    pub fn validate(&self) -> Result<(), ContextError> {
158        if self.schema_version != SCHEMA_VERSION {
159            return Err(ContextError::UnsupportedSchema(self.schema_version.clone()));
160        }
161        if self.content.trim().is_empty() {
162            return Err(ContextError::EmptyContent);
163        }
164        if self.content_digest != hash_bytes(self.content.as_bytes()) {
165            return Err(ContextError::ContentDigest);
166        }
167        if !valid_digest(&self.scope.workspace_id) {
168            return Err(ContextError::WorkspaceIdentity);
169        }
170        if self.authority.producer.trim().is_empty() {
171            return Err(ContextError::EmptyProducer);
172        }
173        if !self.attributes.is_object() {
174            return Err(ContextError::Attributes);
175        }
176        match (&self.source.event_id, self.source.event_seq) {
177            (Some(event_id), Some(_)) if valid_digest(event_id) => {}
178            (None, None) => {}
179            _ => return Err(ContextError::EventAnchor),
180        }
181        if self.source.state_binding.is_some() != self.source.workspace_generation.is_some() {
182            return Err(ContextError::WorkspaceAnchor);
183        }
184        if self
185            .source
186            .state_binding
187            .as_deref()
188            .is_some_and(|binding| !valid_digest(binding))
189        {
190            return Err(ContextError::WorkspaceAnchor);
191        }
192        if self.authority.runtime_owned
193            && (self.source.session_id.is_none()
194                || self.source.event_id.is_none()
195                || self.source.evidence_id.is_none())
196        {
197            return Err(ContextError::RuntimeProvenance);
198        }
199        if self
200            .scope
201            .path
202            .as_deref()
203            .is_some_and(|path| !valid_relative_scope_path(path))
204        {
205            return Err(ContextError::ScopePath);
206        }
207        Ok(())
208    }
209}
210
211/// Durable retrieval lifecycle of one context item.
212#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
213#[serde(rename_all = "snake_case")]
214pub enum MemoryStatus {
215    /// Bound to the latest workspace state observed by the runtime.
216    Current,
217    /// Not bound to a workspace state and therefore never current evidence.
218    Unbound,
219    /// Invalidated by a later observed workspace state.
220    Stale,
221    /// Replaced by a newer context item.
222    Superseded,
223    /// Explicitly invalidated by the runtime.
224    Invalidated,
225}
226
227/// Runtime-owned reason a memory item ceased to be current.
228#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
229#[serde(rename_all = "snake_case")]
230pub enum ContextInvalidationReason {
231    /// A different canonical workspace state was observed.
232    WorkspaceChanged,
233    /// A newer item explicitly replaced this item.
234    Superseded,
235    /// Runtime policy rejected continued use.
236    Policy,
237    /// Source provenance failed validation.
238    Provenance,
239}
240
241/// Durable invalidation receipt.
242#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
243#[serde(rename_all = "camelCase")]
244pub struct ContextInvalidation {
245    /// Runtime-owned invalidation reason.
246    pub reason: ContextInvalidationReason,
247    /// Time the invalidation was persisted.
248    pub at: DateTime<Utc>,
249    /// Workspace epoch at invalidation time.
250    #[serde(skip_serializing_if = "Option::is_none")]
251    pub observed_workspace_generation: Option<u64>,
252    /// Workspace digest that caused invalidation.
253    #[serde(skip_serializing_if = "Option::is_none")]
254    pub observed_state_binding: Option<String>,
255    /// Newer context item when supersession caused invalidation.
256    #[serde(skip_serializing_if = "Option::is_none")]
257    pub source_context_id: Option<Uuid>,
258}
259
260/// Persisted context plus its runtime-owned lifecycle state.
261#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
262#[serde(rename_all = "camelCase")]
263pub struct MemoryItem {
264    /// Public schema identifier.
265    pub schema_version: String,
266    /// Immutable context payload.
267    pub context: ContextItem,
268    /// Current retrieval lifecycle.
269    pub status: MemoryStatus,
270    /// Newer context item, when superseded.
271    #[serde(skip_serializing_if = "Option::is_none")]
272    pub superseded_by: Option<Uuid>,
273    /// Runtime-owned invalidation receipt.
274    #[serde(skip_serializing_if = "Option::is_none")]
275    pub invalidation: Option<ContextInvalidation>,
276    /// First durable persistence time.
277    pub stored_at: DateTime<Utc>,
278    /// Last lifecycle transition time.
279    pub updated_at: DateTime<Utc>,
280}
281
282impl MemoryItem {
283    /// Validates the context and lifecycle consistency.
284    pub fn validate(&self) -> Result<(), ContextError> {
285        if self.schema_version != SCHEMA_VERSION {
286            return Err(ContextError::UnsupportedSchema(self.schema_version.clone()));
287        }
288        self.context.validate()?;
289        if self.updated_at < self.stored_at {
290            return Err(ContextError::TimestampOrder);
291        }
292        match self.status {
293            MemoryStatus::Current => {
294                if self.context.source.state_binding.is_none()
295                    || self.invalidation.is_some()
296                    || self.superseded_by.is_some()
297                {
298                    return Err(ContextError::Lifecycle);
299                }
300            }
301            MemoryStatus::Unbound => {
302                if self.context.source.state_binding.is_some()
303                    || self.invalidation.is_some()
304                    || self.superseded_by.is_some()
305                {
306                    return Err(ContextError::Lifecycle);
307                }
308            }
309            MemoryStatus::Stale | MemoryStatus::Invalidated => {
310                if self.invalidation.is_none() || self.superseded_by.is_some() {
311                    return Err(ContextError::Lifecycle);
312                }
313            }
314            MemoryStatus::Superseded => {
315                if self.invalidation.is_none() || self.superseded_by.is_none() {
316                    return Err(ContextError::Lifecycle);
317                }
318            }
319        }
320        Ok(())
321    }
322}
323
324fn valid_digest(value: &str) -> bool {
325    value.len() == 64
326        && value
327            .bytes()
328            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
329}
330
331fn valid_relative_scope_path(path: &str) -> bool {
332    !path.is_empty()
333        && !path.starts_with('/')
334        && !path.starts_with('\\')
335        && !path.contains(':')
336        && !path
337            .split(['/', '\\'])
338            .any(|component| component.is_empty() || component == "." || component == "..")
339}
340
341/// Context contract validation failure.
342#[derive(Debug, Clone, Error, PartialEq, Eq)]
343pub enum ContextError {
344    /// Unsupported public schema identifier.
345    #[error("unsupported context schema: {0}")]
346    UnsupportedSchema(String),
347    /// Statement body is empty.
348    #[error("context content must not be empty")]
349    EmptyContent,
350    /// Statement digest does not match its body.
351    #[error("context content digest is invalid")]
352    ContentDigest,
353    /// Workspace identity is not a canonical digest.
354    #[error("context workspace identity is invalid")]
355    WorkspaceIdentity,
356    /// Producer identity is empty.
357    #[error("context producer must not be empty")]
358    EmptyProducer,
359    /// Attributes are not a JSON object.
360    #[error("context attributes must be an object")]
361    Attributes,
362    /// Event sequence and event digest are incomplete or malformed.
363    #[error("context event anchor is invalid")]
364    EventAnchor,
365    /// Workspace generation and state binding are incomplete or malformed.
366    #[error("context workspace anchor is invalid")]
367    WorkspaceAnchor,
368    /// Runtime-owned statements lack causal session/event/evidence provenance.
369    #[error("runtime-owned context lacks causal provenance")]
370    RuntimeProvenance,
371    /// Path scope is absolute, ambiguous, or traverses a parent.
372    #[error("context path scope must be a normalized workspace-relative path")]
373    ScopePath,
374    /// Updated timestamp precedes initial persistence.
375    #[error("memory lifecycle timestamp order is invalid")]
376    TimestampOrder,
377    /// Status, invalidation, binding, and supersession disagree.
378    #[error("memory lifecycle state is inconsistent")]
379    Lifecycle,
380}
381
382#[cfg(test)]
383mod tests {
384    use serde_json::json;
385
386    use super::*;
387
388    fn item() -> ContextItem {
389        ContextItem::runtime_observation(
390            "cargo test passed",
391            ContextSource {
392                session_id: Some(Uuid::now_v7()),
393                event_id: Some("a".repeat(64)),
394                event_seq: Some(4),
395                evidence_id: Some(Uuid::now_v7()),
396                workspace_generation: Some(2),
397                state_binding: Some("b".repeat(64)),
398                external_uri: None,
399            },
400            ContextScope {
401                workspace_id: "c".repeat(64),
402                repository: None,
403                branch: None,
404                path: Some("src/lib.rs".to_owned()),
405                symbol: None,
406                task_contract_id: Some(Uuid::now_v7()),
407            },
408            Utc::now(),
409            ContextAuthority {
410                producer: "verify.exec".to_owned(),
411                assurance_level: AssuranceLevel::Observed,
412                runtime_owned: true,
413            },
414            SecretTaint::None,
415            json!({"success": true}),
416        )
417    }
418
419    #[test]
420    fn runtime_context_and_current_memory_validate() {
421        let context = item();
422        context.validate().unwrap();
423        let now = Utc::now();
424        let memory = MemoryItem {
425            schema_version: SCHEMA_VERSION.to_owned(),
426            context,
427            status: MemoryStatus::Current,
428            superseded_by: None,
429            invalidation: None,
430            stored_at: now,
431            updated_at: now,
432        };
433        memory.validate().unwrap();
434    }
435
436    #[test]
437    fn tampered_content_and_parent_scope_are_rejected() {
438        let mut context = item();
439        context.content.push('!');
440        assert_eq!(context.validate(), Err(ContextError::ContentDigest));
441        let mut context = item();
442        context.scope.path = Some("../secret".to_owned());
443        assert_eq!(context.validate(), Err(ContextError::ScopePath));
444    }
445
446    #[test]
447    fn current_memory_requires_a_workspace_binding() {
448        let mut context = item();
449        context.source.workspace_generation = None;
450        context.source.state_binding = None;
451        let now = Utc::now();
452        let memory = MemoryItem {
453            schema_version: SCHEMA_VERSION.to_owned(),
454            context,
455            status: MemoryStatus::Current,
456            superseded_by: None,
457            invalidation: None,
458            stored_at: now,
459            updated_at: now,
460        };
461        assert_eq!(memory.validate(), Err(ContextError::Lifecycle));
462    }
463    #[test]
464    fn context_retrieval_is_not_default_task_evidence() {
465        let requirement = crate::EvidenceRequirement::task();
466        assert!(
467            !requirement
468                .allowed_kinds
469                .contains(&crate::EvidenceKind::Context)
470        );
471    }
472}