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#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
11#[serde(rename_all = "snake_case")]
12pub enum ContextKind {
13 Observation,
15 Assertion,
17 Decision,
19 UserPreference,
21 External,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27#[serde(rename_all = "camelCase")]
28pub struct ContextSource {
29 #[serde(skip_serializing_if = "Option::is_none")]
31 pub session_id: Option<Uuid>,
32 #[serde(skip_serializing_if = "Option::is_none")]
34 pub event_id: Option<String>,
35 #[serde(skip_serializing_if = "Option::is_none")]
37 pub event_seq: Option<u64>,
38 #[serde(skip_serializing_if = "Option::is_none")]
40 pub evidence_id: Option<Uuid>,
41 #[serde(skip_serializing_if = "Option::is_none")]
43 pub workspace_generation: Option<u64>,
44 #[serde(skip_serializing_if = "Option::is_none")]
46 pub state_binding: Option<String>,
47 #[serde(skip_serializing_if = "Option::is_none")]
49 pub external_uri: Option<String>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
54#[serde(rename_all = "camelCase")]
55pub struct ContextScope {
56 pub workspace_id: String,
58 #[serde(skip_serializing_if = "Option::is_none")]
60 pub repository: Option<String>,
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub branch: Option<String>,
64 #[serde(skip_serializing_if = "Option::is_none")]
66 pub path: Option<String>,
67 #[serde(skip_serializing_if = "Option::is_none")]
69 pub symbol: Option<String>,
70 #[serde(skip_serializing_if = "Option::is_none")]
72 pub task_contract_id: Option<Uuid>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
77#[serde(rename_all = "camelCase")]
78pub struct ContextAuthority {
79 pub producer: String,
81 pub assurance_level: AssuranceLevel,
83 pub runtime_owned: bool,
85}
86
87#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
89#[serde(rename_all = "snake_case")]
90pub enum SecretTaint {
91 #[default]
93 None,
94 Redacted,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
100#[serde(rename_all = "camelCase")]
101pub struct ContextItem {
102 pub schema_version: String,
104 pub id: Uuid,
106 pub kind: ContextKind,
108 pub content: String,
110 pub content_digest: String,
112 pub source: ContextSource,
114 pub scope: ContextScope,
116 pub observed_at: DateTime<Utc>,
118 pub authority: ContextAuthority,
120 #[serde(default)]
122 pub secret_taint: SecretTaint,
123 #[serde(default)]
125 pub attributes: Value,
126}
127
128impl ContextItem {
129 #[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 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#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
213#[serde(rename_all = "snake_case")]
214pub enum MemoryStatus {
215 Current,
217 Unbound,
219 Stale,
221 Superseded,
223 Invalidated,
225}
226
227#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
229#[serde(rename_all = "snake_case")]
230pub enum ContextInvalidationReason {
231 WorkspaceChanged,
233 Superseded,
235 Policy,
237 Provenance,
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
243#[serde(rename_all = "camelCase")]
244pub struct ContextInvalidation {
245 pub reason: ContextInvalidationReason,
247 pub at: DateTime<Utc>,
249 #[serde(skip_serializing_if = "Option::is_none")]
251 pub observed_workspace_generation: Option<u64>,
252 #[serde(skip_serializing_if = "Option::is_none")]
254 pub observed_state_binding: Option<String>,
255 #[serde(skip_serializing_if = "Option::is_none")]
257 pub source_context_id: Option<Uuid>,
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
262#[serde(rename_all = "camelCase")]
263pub struct MemoryItem {
264 pub schema_version: String,
266 pub context: ContextItem,
268 pub status: MemoryStatus,
270 #[serde(skip_serializing_if = "Option::is_none")]
272 pub superseded_by: Option<Uuid>,
273 #[serde(skip_serializing_if = "Option::is_none")]
275 pub invalidation: Option<ContextInvalidation>,
276 pub stored_at: DateTime<Utc>,
278 pub updated_at: DateTime<Utc>,
280}
281
282impl MemoryItem {
283 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#[derive(Debug, Clone, Error, PartialEq, Eq)]
343pub enum ContextError {
344 #[error("unsupported context schema: {0}")]
346 UnsupportedSchema(String),
347 #[error("context content must not be empty")]
349 EmptyContent,
350 #[error("context content digest is invalid")]
352 ContentDigest,
353 #[error("context workspace identity is invalid")]
355 WorkspaceIdentity,
356 #[error("context producer must not be empty")]
358 EmptyProducer,
359 #[error("context attributes must be an object")]
361 Attributes,
362 #[error("context event anchor is invalid")]
364 EventAnchor,
365 #[error("context workspace anchor is invalid")]
367 WorkspaceAnchor,
368 #[error("runtime-owned context lacks causal provenance")]
370 RuntimeProvenance,
371 #[error("context path scope must be a normalized workspace-relative path")]
373 ScopePath,
374 #[error("memory lifecycle timestamp order is invalid")]
376 TimestampOrder,
377 #[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}