use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContextPack {
pub memory_digest: String,
pub doctrine_refs: Vec<String>,
pub task: String,
pub expires_at: Option<u64>,
}
impl ContextPack {
pub fn new(task: impl Into<String>) -> Self {
Self {
memory_digest: String::new(),
doctrine_refs: Vec::new(),
task: task.into(),
expires_at: None,
}
}
pub fn is_expired(&self, now_ms: u64) -> bool {
matches!(self.expires_at, Some(deadline) if now_ms >= deadline)
}
pub fn from_cortex_json(json: &str) -> Result<Self, anyhow::Error> {
#[derive(Deserialize)]
struct UpstreamPack {
#[serde(default)]
task: String,
#[serde(default)]
active_doctrine_ids: Vec<serde_json::Value>,
#[serde(default)]
context_pack_id: Option<serde_json::Value>,
}
let parsed: UpstreamPack = serde_json::from_str(json)
.map_err(|e| anyhow::anyhow!("parse cortex context pack json: {e}"))?;
if parsed.task.trim().is_empty() {
anyhow::bail!("cortex context pack: `task` must be present and non-empty");
}
let doctrine_refs: Vec<String> = parsed
.active_doctrine_ids
.into_iter()
.map(|v| match v {
serde_json::Value::String(s) => s,
other => other.to_string(),
})
.collect();
let memory_digest = match parsed.context_pack_id {
Some(serde_json::Value::String(s)) => s,
Some(other) => other.to_string(),
None => String::new(),
};
Ok(Self {
memory_digest,
doctrine_refs,
task: parsed.task,
expires_at: None,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn json_roundtrip_preserves_shape() {
let pack = ContextPack {
memory_digest: "blake3:abc".into(),
doctrine_refs: vec!["principle:no-silent-corruption".into()],
task: "audit ledger row 42".into(),
expires_at: Some(1_700_000_000_000),
};
let json = serde_json::to_string(&pack).expect("serialize");
let round: ContextPack = serde_json::from_str(&json).expect("deserialize");
assert_eq!(pack, round);
}
#[test]
fn missing_expires_at_round_trips() {
let pack = ContextPack::new("simple task");
let json = serde_json::to_string(&pack).unwrap();
assert!(json.contains("\"expires_at\":null"));
let round: ContextPack = serde_json::from_str(&json).unwrap();
assert_eq!(round.expires_at, None);
}
#[test]
fn is_expired_respects_deadline() {
let mut pack = ContextPack::new("t");
assert!(!pack.is_expired(0));
pack.expires_at = Some(100);
assert!(!pack.is_expired(99));
assert!(pack.is_expired(100));
assert!(pack.is_expired(101));
}
#[test]
fn from_cortex_json_projects_task_and_doctrine_ids() {
let json = serde_json::json!({
"context_pack_id": "pack-7f3c",
"task": "audit ledger row 42",
"max_tokens": 2048,
"pack_mode": "external",
"redaction_policy": { "raw_event_payloads": "excluded" },
"selected_refs": [],
"active_doctrine_ids": [
"principle:no-silent-corruption",
"principle:events-are-evidence"
],
"conflicts": [],
"exclusions": [],
"selection_audit": {
"pack_mode": "external",
"redaction_policy": { "raw_event_payloads": "excluded" },
"estimated_tokens": 64,
"included": [],
"exclusions": []
}
})
.to_string();
let pack = ContextPack::from_cortex_json(&json).expect("parse upstream pack");
assert_eq!(pack.task, "audit ledger row 42");
assert_eq!(pack.memory_digest, "pack-7f3c");
assert_eq!(
pack.doctrine_refs,
vec![
"principle:no-silent-corruption".to_string(),
"principle:events-are-evidence".to_string()
]
);
assert_eq!(pack.expires_at, None);
}
#[test]
fn from_cortex_json_rejects_missing_task() {
let json = r#"{ "task": "", "active_doctrine_ids": [] }"#;
let err =
ContextPack::from_cortex_json(json).expect_err("empty task must surface a parse error");
assert!(format!("{err}").contains("task"));
}
#[test]
fn from_cortex_json_tolerates_unknown_fields() {
let json = r#"{
"task": "tolerant parse",
"active_doctrine_ids": [],
"future_field": { "anything": [1, 2, 3] }
}"#;
let pack = ContextPack::from_cortex_json(json).expect("unknown fields tolerated");
assert_eq!(pack.task, "tolerant parse");
assert!(pack.doctrine_refs.is_empty());
}
}