cellos-cortex 0.1.0

Bridge between CellOS execution cells and the Cortex doctrine layer — DoctrineAuthorityPolicy, CortexCellRunner, CellosLedgerEmitter.
//! Bridge-shaped context pack — the *wire* form of a Cortex `ContextPack` as it
//! crosses into CellOS.
//!
//! See ADR 0007 for why this is a separate, flatter shape from
//! `cortex_context::ContextPack`. The richer upstream type lives entirely
//! inside Cortex; the boundary translator (in the dispatcher that hands work
//! to [`crate::runner::CortexCellRunner`]) is responsible for reducing it to
//! this transport contract.
//!
//! # Compatibility with `cortex_context::ContextPack` (real Cortex type)
//!
//! **TODO (bridge compatibility gap):** the real Cortex `ContextPack` —
//! defined at `crates/cortex-context/src/pack.rs` in the upstream Cortex
//! workspace — carries the full Cortex authority lattice (`pack_mode`,
//! `redaction_policy`, `selected_refs[]` with `claim_ceiling` /
//! `provenance_class` / `semantic_trust`, `conflicts`, `exclusions`,
//! `selection_audit`, etc.). Importing it would pull the entire
//! `cortex-core` / `cortex-memory` / `cortex-retrieval` graph into CellOS,
//! which ADR 0007 explicitly forbids (the bridge must not couple to either
//! side's internals). It also has no top-level `expires_at` field — that
//! concept does not exist in the upstream type.
//!
//! Until / unless Cortex publishes a stable wire crate, we cannot use the
//! real type directly. Instead, this module ships
//! [`ContextPack::from_cortex_json`], which parses the real Cortex wire JSON
//! (CamelCase field names omitted — `cortex-context` uses the default
//! snake_case serde representation) and projects it down to the bridge
//! shape. The projection rules are documented on that constructor.

use serde::{Deserialize, Serialize};

/// A bounded context package from Cortex: memory + doctrine + task.
///
/// Fields:
/// - `memory_digest` — opaque hash (BLAKE3 hex is the canonical Cortex choice)
///   of the memory selection the upstream pack settled on; CellOS does not
///   resolve or interpret memory bodies.
/// - `doctrine_refs` — opaque principle / doctrine identifiers that the
///   dispatched cell is expected to honour. CellOS does not interpret these
///   either; they are propagated into the resulting cell's correlation
///   metadata for audit.
/// - `task` — human-readable task description; supplied verbatim into the
///   cell's `spec.run.argv` (or environment) by the runner.
/// - `expires_at` — optional Unix epoch milliseconds at which Cortex
///   considers this pack stale. The runner clamps `lifetime.ttlSeconds`
///   against this when building the cell spec.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContextPack {
    /// Hash digest of relevant memories (opaque to CellOS).
    pub memory_digest: String,
    /// Principle / doctrine identifiers applicable to this task (opaque to CellOS).
    pub doctrine_refs: Vec<String>,
    /// Task description handed to the dispatched agent.
    pub task: String,
    /// Optional pack expiry in Unix milliseconds.
    pub expires_at: Option<u64>,
}

impl ContextPack {
    /// Convenience constructor for callers building packs by hand (mostly tests).
    pub fn new(task: impl Into<String>) -> Self {
        Self {
            memory_digest: String::new(),
            doctrine_refs: Vec::new(),
            task: task.into(),
            expires_at: None,
        }
    }

    /// True when `expires_at` is set and has elapsed relative to `now_ms`.
    pub fn is_expired(&self, now_ms: u64) -> bool {
        matches!(self.expires_at, Some(deadline) if now_ms >= deadline)
    }

    /// Parse a real Cortex `cortex_context::ContextPack` JSON payload and
    /// project it down to the bridge shape.
    ///
    /// We deliberately do not depend on the upstream `cortex-context` crate
    /// (see the module-level TODO). Instead this method accepts the upstream
    /// wire JSON and pulls out only the fields the bridge defines:
    ///
    /// - `task` ← `task` (verbatim; required and must be non-empty).
    /// - `doctrine_refs` ← `active_doctrine_ids[]` (stringified — upstream
    ///   ids are opaque to CellOS, we just propagate them).
    /// - `memory_digest` ← `context_pack_id` if present (BLAKE3 / opaque
    ///   string; CellOS treats it as a correlation key). When absent we
    ///   leave the digest empty — runner code already handles that case.
    /// - `expires_at` ← always `None`. The upstream type has no expiry; the
    ///   bridge contract carries this as an *operator-supplied* override
    ///   (see `runner::CortexCellRunner::with_default_ttl_seconds`).
    ///
    /// Returns an error when the JSON cannot be parsed or `task` is missing
    /// / empty. Unknown extra fields in the upstream payload are accepted
    /// silently (forwards-compatible with Cortex schema evolution).
    pub fn from_cortex_json(json: &str) -> Result<Self, anyhow::Error> {
        // Permissively typed shadow of the upstream type — we only model the
        // fields we project, and everything else is ignored. `#[serde(default)]`
        // on each field keeps us forwards-compatible if upstream renames or
        // drops something we don't care about.
        #[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");
        }

        // Upstream `DoctrineId` may serialize as a bare string or as a
        // tagged object; we accept either, falling back to `to_string()` so
        // we never drop an id silently.
        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() {
        // Shape of upstream `cortex_context::ContextPack` JSON serialization —
        // task + active_doctrine_ids + context_pack_id, plus a bunch of
        // fields the bridge intentionally ignores.
        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()
            ]
        );
        // The upstream type has no expires_at; bridge defaults to None.
        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());
    }
}