use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use thiserror::Error;
use uuid::Uuid;
use crate::{AssuranceLevel, SCHEMA_VERSION, hash_bytes};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ContextKind {
Observation,
Assertion,
Decision,
UserPreference,
External,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ContextSource {
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")]
pub event_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub event_seq: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub evidence_id: Option<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace_generation: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub state_binding: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub external_uri: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ContextScope {
pub workspace_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub repository: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub symbol: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub task_contract_id: Option<Uuid>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ContextAuthority {
pub producer: String,
pub assurance_level: AssuranceLevel,
pub runtime_owned: bool,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SecretTaint {
#[default]
None,
Redacted,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ContextItem {
pub schema_version: String,
pub id: Uuid,
pub kind: ContextKind,
pub content: String,
pub content_digest: String,
pub source: ContextSource,
pub scope: ContextScope,
pub observed_at: DateTime<Utc>,
pub authority: ContextAuthority,
#[serde(default)]
pub secret_taint: SecretTaint,
#[serde(default)]
pub attributes: Value,
}
impl ContextItem {
#[must_use]
pub fn runtime_observation(
content: impl Into<String>,
source: ContextSource,
scope: ContextScope,
observed_at: DateTime<Utc>,
authority: ContextAuthority,
secret_taint: SecretTaint,
attributes: Value,
) -> Self {
let content = content.into();
Self {
schema_version: SCHEMA_VERSION.to_owned(),
id: Uuid::now_v7(),
kind: ContextKind::Observation,
content_digest: hash_bytes(content.as_bytes()),
content,
source,
scope,
observed_at,
authority,
secret_taint,
attributes,
}
}
pub fn validate(&self) -> Result<(), ContextError> {
if self.schema_version != SCHEMA_VERSION {
return Err(ContextError::UnsupportedSchema(self.schema_version.clone()));
}
if self.content.trim().is_empty() {
return Err(ContextError::EmptyContent);
}
if self.content_digest != hash_bytes(self.content.as_bytes()) {
return Err(ContextError::ContentDigest);
}
if !valid_digest(&self.scope.workspace_id) {
return Err(ContextError::WorkspaceIdentity);
}
if self.authority.producer.trim().is_empty() {
return Err(ContextError::EmptyProducer);
}
if !self.attributes.is_object() {
return Err(ContextError::Attributes);
}
match (&self.source.event_id, self.source.event_seq) {
(Some(event_id), Some(_)) if valid_digest(event_id) => {}
(None, None) => {}
_ => return Err(ContextError::EventAnchor),
}
if self.source.state_binding.is_some() != self.source.workspace_generation.is_some() {
return Err(ContextError::WorkspaceAnchor);
}
if self
.source
.state_binding
.as_deref()
.is_some_and(|binding| !valid_digest(binding))
{
return Err(ContextError::WorkspaceAnchor);
}
if self.authority.runtime_owned
&& (self.source.session_id.is_none()
|| self.source.event_id.is_none()
|| self.source.evidence_id.is_none())
{
return Err(ContextError::RuntimeProvenance);
}
if self
.scope
.path
.as_deref()
.is_some_and(|path| !valid_relative_scope_path(path))
{
return Err(ContextError::ScopePath);
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum MemoryStatus {
Current,
Unbound,
Stale,
Superseded,
Invalidated,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ContextInvalidationReason {
WorkspaceChanged,
Superseded,
Policy,
Provenance,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ContextInvalidation {
pub reason: ContextInvalidationReason,
pub at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub observed_workspace_generation: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub observed_state_binding: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_context_id: Option<Uuid>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct MemoryItem {
pub schema_version: String,
pub context: ContextItem,
pub status: MemoryStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub superseded_by: Option<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")]
pub invalidation: Option<ContextInvalidation>,
pub stored_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl MemoryItem {
pub fn validate(&self) -> Result<(), ContextError> {
if self.schema_version != SCHEMA_VERSION {
return Err(ContextError::UnsupportedSchema(self.schema_version.clone()));
}
self.context.validate()?;
if self.updated_at < self.stored_at {
return Err(ContextError::TimestampOrder);
}
match self.status {
MemoryStatus::Current => {
if self.context.source.state_binding.is_none()
|| self.invalidation.is_some()
|| self.superseded_by.is_some()
{
return Err(ContextError::Lifecycle);
}
}
MemoryStatus::Unbound => {
if self.context.source.state_binding.is_some()
|| self.invalidation.is_some()
|| self.superseded_by.is_some()
{
return Err(ContextError::Lifecycle);
}
}
MemoryStatus::Stale | MemoryStatus::Invalidated => {
if self.invalidation.is_none() || self.superseded_by.is_some() {
return Err(ContextError::Lifecycle);
}
}
MemoryStatus::Superseded => {
if self.invalidation.is_none() || self.superseded_by.is_none() {
return Err(ContextError::Lifecycle);
}
}
}
Ok(())
}
}
fn valid_digest(value: &str) -> bool {
value.len() == 64
&& value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
fn valid_relative_scope_path(path: &str) -> bool {
!path.is_empty()
&& !path.starts_with('/')
&& !path.starts_with('\\')
&& !path.contains(':')
&& !path
.split(['/', '\\'])
.any(|component| component.is_empty() || component == "." || component == "..")
}
#[derive(Debug, Clone, Error, PartialEq, Eq)]
pub enum ContextError {
#[error("unsupported context schema: {0}")]
UnsupportedSchema(String),
#[error("context content must not be empty")]
EmptyContent,
#[error("context content digest is invalid")]
ContentDigest,
#[error("context workspace identity is invalid")]
WorkspaceIdentity,
#[error("context producer must not be empty")]
EmptyProducer,
#[error("context attributes must be an object")]
Attributes,
#[error("context event anchor is invalid")]
EventAnchor,
#[error("context workspace anchor is invalid")]
WorkspaceAnchor,
#[error("runtime-owned context lacks causal provenance")]
RuntimeProvenance,
#[error("context path scope must be a normalized workspace-relative path")]
ScopePath,
#[error("memory lifecycle timestamp order is invalid")]
TimestampOrder,
#[error("memory lifecycle state is inconsistent")]
Lifecycle,
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
fn item() -> ContextItem {
ContextItem::runtime_observation(
"cargo test passed",
ContextSource {
session_id: Some(Uuid::now_v7()),
event_id: Some("a".repeat(64)),
event_seq: Some(4),
evidence_id: Some(Uuid::now_v7()),
workspace_generation: Some(2),
state_binding: Some("b".repeat(64)),
external_uri: None,
},
ContextScope {
workspace_id: "c".repeat(64),
repository: None,
branch: None,
path: Some("src/lib.rs".to_owned()),
symbol: None,
task_contract_id: Some(Uuid::now_v7()),
},
Utc::now(),
ContextAuthority {
producer: "verify.exec".to_owned(),
assurance_level: AssuranceLevel::Observed,
runtime_owned: true,
},
SecretTaint::None,
json!({"success": true}),
)
}
#[test]
fn runtime_context_and_current_memory_validate() {
let context = item();
context.validate().unwrap();
let now = Utc::now();
let memory = MemoryItem {
schema_version: SCHEMA_VERSION.to_owned(),
context,
status: MemoryStatus::Current,
superseded_by: None,
invalidation: None,
stored_at: now,
updated_at: now,
};
memory.validate().unwrap();
}
#[test]
fn tampered_content_and_parent_scope_are_rejected() {
let mut context = item();
context.content.push('!');
assert_eq!(context.validate(), Err(ContextError::ContentDigest));
let mut context = item();
context.scope.path = Some("../secret".to_owned());
assert_eq!(context.validate(), Err(ContextError::ScopePath));
}
#[test]
fn current_memory_requires_a_workspace_binding() {
let mut context = item();
context.source.workspace_generation = None;
context.source.state_binding = None;
let now = Utc::now();
let memory = MemoryItem {
schema_version: SCHEMA_VERSION.to_owned(),
context,
status: MemoryStatus::Current,
superseded_by: None,
invalidation: None,
stored_at: now,
updated_at: now,
};
assert_eq!(memory.validate(), Err(ContextError::Lifecycle));
}
#[test]
fn context_retrieval_is_not_default_task_evidence() {
let requirement = crate::EvidenceRequirement::task();
assert!(
!requirement
.allowed_kinds
.contains(&crate::EvidenceKind::Context)
);
}
}