#![warn(missing_docs)]
use chrono::Utc;
use cortex_context::ContextPack;
use cortex_core::{
AuthorityClass, ClaimCeiling, ClaimProofState, ContextPackId, CorrelationId, CortexResult,
Event, EventId, EventSource, EventType, PolicyOutcome, RuntimeMode, SCHEMA_VERSION,
};
use cortex_llm::{LlmAdapter, LlmError, LlmMessage, LlmRequest, LlmResponse, LlmRole, TokenUsage};
use serde::{Deserialize, Serialize};
use serde_json::json;
use thiserror::Error;
pub mod claims;
pub use claims::{
compile_runtime_claim, development_ledger_use_decision, require_runtime_claim,
require_runtime_claim_with_policy, runtime_claim_preflight,
runtime_claim_preflight_with_policy, CompiledRuntimeClaim, DevelopmentLedgerUse,
DevelopmentLedgerUseDecision, RuntimeClaimKind, RuntimeClaimPreflight,
};
pub const RUNTIME_TRACE_SCHEMA_VERSION: u16 = 1;
pub const RUNTIME_RUN_OPERATION: &str = "runtime.run";
pub const DEFAULT_RUNTIME_MODEL: &str = "cortex-runtime-v1";
pub const DEFAULT_TIMEOUT_MS: u64 = 30_000;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Run {
pub correlation_id: CorrelationId,
pub task: String,
pub pack: ContextPack,
pub model: String,
pub system: String,
pub temperature: f32,
pub max_tokens: u32,
pub timeout_ms: u64,
pub session_id: Option<String>,
pub domain_tags: Vec<String>,
pub runtime_mode: RuntimeMode,
}
impl Run {
pub fn new(task: impl Into<String>, pack: ContextPack) -> Result<Self, RuntimeError> {
let task = task.into();
let max_tokens = u32::try_from(pack.max_tokens).map_err(|_| {
RuntimeError::Validation(format!(
"context pack max_tokens {} exceeds u32::MAX",
pack.max_tokens
))
})?;
Ok(Self {
correlation_id: CorrelationId::new(),
task,
pack,
model: DEFAULT_RUNTIME_MODEL.to_string(),
system: "You are a Cortex child agent. Use the supplied ContextPack as declared context; do not infer hidden memory.".to_string(),
temperature: 0.0,
max_tokens,
timeout_ms: DEFAULT_TIMEOUT_MS,
session_id: None,
domain_tags: vec!["runtime".to_string()],
runtime_mode: RuntimeMode::LocalUnsigned,
})
}
fn validate(&self) -> Result<(), RuntimeError> {
if self.task.trim().is_empty() {
return Err(RuntimeError::Validation(
"runtime task must not be empty".to_string(),
));
}
if self.pack.task != self.task {
return Err(RuntimeError::Validation(format!(
"runtime task `{}` does not match context pack task `{}`",
self.task, self.pack.task
)));
}
if self.model.trim().is_empty() {
return Err(RuntimeError::Validation(
"runtime model must not be empty".to_string(),
));
}
if self.max_tokens == 0 {
return Err(RuntimeError::Validation(
"runtime max_tokens must be greater than zero".to_string(),
));
}
Ok(())
}
fn llm_request(&self) -> LlmRequest {
LlmRequest {
model: self.model.clone(),
system: self.system.clone(),
messages: vec![LlmMessage {
role: LlmRole::User,
content: json!({
"task": self.task,
"context_pack_id": self.pack.context_pack_id,
"context_pack": self.pack,
})
.to_string(),
}],
temperature: self.temperature,
max_tokens: self.max_tokens,
json_schema: None,
timeout_ms: self.timeout_ms,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RunTraceStatus {
Completed,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunObservability {
pub audit_schema_version: u16,
pub operation: String,
pub status: RunTraceStatus,
pub correlation_id: CorrelationId,
pub context_pack_id: ContextPackId,
pub adapter_id: String,
pub model: String,
pub runtime_mode: RuntimeMode,
pub proof_state: ClaimProofState,
pub claim_ceiling: ClaimCeiling,
pub trusted_run_history: bool,
pub context_policy_outcome: PolicyOutcome,
pub response_hash: String,
}
impl RunObservability {
fn completed(report: &RunReport) -> Self {
Self {
audit_schema_version: RUNTIME_TRACE_SCHEMA_VERSION,
operation: RUNTIME_RUN_OPERATION.to_string(),
status: RunTraceStatus::Completed,
correlation_id: report.correlation_id,
context_pack_id: report.context_pack_id,
adapter_id: report.adapter_id.clone(),
model: report.model.clone(),
runtime_mode: report.runtime_mode,
proof_state: report.proof_state,
claim_ceiling: report.claim_ceiling,
trusted_run_history: report.trusted_run_history,
context_policy_outcome: report.context_policy_outcome,
response_hash: report.raw_hash.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunReport {
pub correlation_id: CorrelationId,
pub task: String,
pub context_pack_id: ContextPackId,
pub run_observability: RunObservability,
pub adapter_id: String,
pub model: String,
pub raw_hash: String,
pub usage: Option<TokenUsage>,
pub prompt_hash: String,
pub runtime_mode: RuntimeMode,
pub proof_state: ClaimProofState,
pub claim_ceiling: ClaimCeiling,
pub trusted_run_history: bool,
pub downgrade_reasons: Vec<String>,
pub context_policy_outcome: PolicyOutcome,
pub agent_response_event: Event,
}
impl RunReport {
pub fn refresh_observability(&mut self) {
self.run_observability = RunObservability::completed(self);
}
}
#[derive(Debug, Error)]
pub enum RuntimeError {
#[error("validation failed: {0}")]
Validation(String),
#[error("llm adapter failed: {0}")]
Adapter(#[from] LlmError),
}
pub async fn run(
task: impl Into<String>,
adapter: &dyn LlmAdapter,
pack: ContextPack,
) -> Result<RunReport, RuntimeError> {
run_configured(Run::new(task, pack)?, adapter).await
}
pub async fn run_configured(run: Run, adapter: &dyn LlmAdapter) -> Result<RunReport, RuntimeError> {
run.validate()?;
tracing::info!(
audit_schema_version = RUNTIME_TRACE_SCHEMA_VERSION,
operation = RUNTIME_RUN_OPERATION,
correlation_id = %run.correlation_id,
context_pack_id = %run.pack.context_pack_id,
adapter_id = adapter.adapter_id(),
model = %run.model,
status = "started",
"runtime run"
);
let request = run.llm_request();
let prompt_hash = request.prompt_hash();
let response = match adapter.complete(request).await {
Ok(response) => response,
Err(err) => {
tracing::warn!(
audit_schema_version = RUNTIME_TRACE_SCHEMA_VERSION,
operation = RUNTIME_RUN_OPERATION,
correlation_id = %run.correlation_id,
context_pack_id = %run.pack.context_pack_id,
adapter_id = adapter.adapter_id(),
model = %run.model,
status = "failed",
error_kind = "adapter",
"runtime run"
);
return Err(err.into());
}
};
let adapter_id = adapter.adapter_id().to_string();
let runtime_mode = run.runtime_mode;
let event = agent_response_event(&run, &adapter_id, &response, runtime_mode);
let context_policy = run.pack.policy_decision();
let claim = claims::compile_runtime_claim(
"development ledger run output",
claims::RuntimeClaimKind::Advisory,
runtime_mode,
AuthorityClass::Observed,
ClaimProofState::Partial,
runtime_mode.claim_ceiling(),
);
let correlation_id = run.correlation_id;
let context_pack_id = run.pack.context_pack_id;
tracing::info!(
audit_schema_version = RUNTIME_TRACE_SCHEMA_VERSION,
operation = RUNTIME_RUN_OPERATION,
correlation_id = %correlation_id,
context_pack_id = %context_pack_id,
adapter_id = %adapter_id,
model = %response.model,
status = "completed",
context_policy_outcome = ?context_policy.final_outcome,
response_hash = %response.raw_hash,
"runtime run"
);
let mut report = RunReport {
correlation_id,
task: run.task,
context_pack_id,
run_observability: RunObservability {
audit_schema_version: RUNTIME_TRACE_SCHEMA_VERSION,
operation: RUNTIME_RUN_OPERATION.to_string(),
status: RunTraceStatus::Completed,
correlation_id,
context_pack_id,
adapter_id: adapter_id.clone(),
model: response.model.clone(),
runtime_mode: claim.runtime_mode,
proof_state: claim.proof_state,
claim_ceiling: claim.effective_ceiling,
trusted_run_history: false,
context_policy_outcome: context_policy.final_outcome,
response_hash: response.raw_hash.clone(),
},
adapter_id,
model: response.model,
raw_hash: response.raw_hash,
usage: response.usage,
prompt_hash,
runtime_mode: claim.runtime_mode,
proof_state: claim.proof_state,
claim_ceiling: claim.effective_ceiling,
trusted_run_history: false,
downgrade_reasons: claim.reasons,
context_policy_outcome: context_policy.final_outcome,
agent_response_event: event,
};
report.refresh_observability();
Ok(report)
}
fn agent_response_event(
run: &Run,
adapter_id: &str,
response: &LlmResponse,
runtime_mode: RuntimeMode,
) -> Event {
let mut forbidden_uses = vec![
"audit_export",
"compliance_evidence",
"cross_system_trust_decision",
"external_reporting",
];
if runtime_mode == RuntimeMode::RemoteUnsigned {
forbidden_uses.push("remote_unsigned");
}
let now = Utc::now();
Event {
id: EventId::new(),
schema_version: SCHEMA_VERSION,
observed_at: now,
recorded_at: now,
source: EventSource::ChildAgent {
model: response.model.clone(),
},
event_type: EventType::AgentResponse,
trace_id: None,
session_id: run.session_id.clone(),
domain_tags: run.domain_tags.clone(),
payload: json!({
"task": run.task,
"correlation_id": run.correlation_id,
"context_pack_id": run.pack.context_pack_id,
"adapter_id": adapter_id,
"model": response.model,
"text": response.text,
"parsed_json": response.parsed_json,
"raw_hash": response.raw_hash,
"usage": response.usage,
"runtime_mode": runtime_mode,
"ledger_authority": "development",
"signed_ledger_authority": false,
"trusted_run_history": false,
"forbidden_uses": forbidden_uses,
}),
payload_hash: String::new(),
prev_event_hash: None,
event_hash: String::new(),
}
}
pub fn run_stub(adapter: &dyn LlmAdapter) -> CortexResult<()> {
tracing::info!(adapter = adapter.adapter_id(), "run stub");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use cortex_context::ContextPackBuilder;
use cortex_llm::blake3_hex;
use std::sync::{Arc, Mutex};
#[derive(Debug)]
struct FixedAdapter {
seen: Arc<Mutex<Vec<LlmRequest>>>,
}
#[async_trait]
impl LlmAdapter for FixedAdapter {
fn adapter_id(&self) -> &'static str {
"fixed-runtime"
}
async fn complete(&self, req: LlmRequest) -> Result<LlmResponse, LlmError> {
self.seen
.lock()
.expect("request mutex not poisoned")
.push(req);
let text = "runtime response".to_string();
Ok(LlmResponse {
text: text.clone(),
parsed_json: None,
model: DEFAULT_RUNTIME_MODEL.to_string(),
usage: Some(TokenUsage {
prompt_tokens: 12,
completion_tokens: 3,
}),
raw_hash: blake3_hex(text.as_bytes()),
})
}
}
fn pack(task: &str) -> ContextPack {
ContextPackBuilder::new(task, 512)
.build()
.expect("valid empty context pack")
}
#[tokio::test]
async fn run_builds_agent_response_event_linked_to_pack() {
let task = "answer with declared context";
let pack = pack(task);
let context_pack_id = pack.context_pack_id;
let seen = Arc::new(Mutex::new(Vec::new()));
let adapter = FixedAdapter {
seen: Arc::clone(&seen),
};
let report = run(task, &adapter, pack)
.await
.expect("runtime run succeeds");
assert_eq!(report.context_pack_id, context_pack_id);
assert!(report.correlation_id.to_string().starts_with("cor_"));
assert_eq!(
report.run_observability.correlation_id,
report.correlation_id
);
assert_eq!(report.run_observability.context_pack_id, context_pack_id);
assert_eq!(
report.run_observability.audit_schema_version,
RUNTIME_TRACE_SCHEMA_VERSION
);
assert_eq!(report.run_observability.operation, RUNTIME_RUN_OPERATION);
assert_eq!(report.run_observability.status, RunTraceStatus::Completed);
assert_eq!(report.run_observability.adapter_id, "fixed-runtime");
assert_eq!(report.run_observability.model, DEFAULT_RUNTIME_MODEL);
assert_eq!(
report.run_observability.runtime_mode,
RuntimeMode::LocalUnsigned
);
assert_eq!(
report.run_observability.proof_state,
ClaimProofState::Partial
);
assert_eq!(
report.run_observability.claim_ceiling,
ClaimCeiling::LocalUnsigned
);
assert!(!report.run_observability.trusted_run_history);
assert_eq!(
report.run_observability.context_policy_outcome,
PolicyOutcome::Allow
);
assert_eq!(report.run_observability.response_hash, report.raw_hash);
assert_eq!(report.adapter_id, "fixed-runtime");
assert_eq!(
report.agent_response_event.event_type,
EventType::AgentResponse
);
assert_eq!(
report.agent_response_event.payload["correlation_id"],
json!(report.correlation_id)
);
assert_eq!(
report.agent_response_event.payload["context_pack_id"],
json!(context_pack_id)
);
assert_eq!(
report.agent_response_event.payload["ledger_authority"],
json!("development")
);
assert_eq!(
report.agent_response_event.payload["signed_ledger_authority"],
json!(false)
);
assert_eq!(report.runtime_mode, RuntimeMode::LocalUnsigned);
assert_eq!(report.proof_state, ClaimProofState::Partial);
assert_eq!(report.claim_ceiling, ClaimCeiling::LocalUnsigned);
assert!(!report.trusted_run_history);
assert_eq!(report.context_policy_outcome, PolicyOutcome::Allow);
assert!(report
.downgrade_reasons
.iter()
.any(|reason| reason.contains("proof state Partial")));
assert!(report.agent_response_event.payload["forbidden_uses"]
.as_array()
.expect("forbidden_uses array")
.iter()
.any(|value| value.as_str() == Some("audit_export")));
assert_eq!(
report.agent_response_event.source,
EventSource::ChildAgent {
model: DEFAULT_RUNTIME_MODEL.to_string()
}
);
assert_eq!(report.agent_response_event.payload_hash, "");
assert_eq!(report.agent_response_event.event_hash, "");
}
#[tokio::test]
async fn run_observability_excludes_prompt_and_raw_context() {
let task = "observe without leaking prompt";
let seen = Arc::new(Mutex::new(Vec::new()));
let adapter = FixedAdapter {
seen: Arc::clone(&seen),
};
let report = run(task, &adapter, pack(task))
.await
.expect("runtime run succeeds");
let observability =
serde_json::to_value(&report.run_observability).expect("observability serializes");
assert_eq!(
observability["correlation_id"],
json!(report.correlation_id)
);
assert_eq!(
observability["context_pack_id"],
json!(report.context_pack_id)
);
for forbidden_key in [
"task",
"system",
"messages",
"prompt",
"prompt_hash",
"context_pack",
"selected_refs",
"raw_context",
"raw_event_payload",
"response_text",
"text",
] {
assert!(
observability.get(forbidden_key).is_none(),
"run observability must not expose {forbidden_key}"
);
}
let serialized =
serde_json::to_string(&observability).expect("observability serializes to string");
assert!(
!serialized.contains(task),
"run observability must not include task text"
);
}
#[tokio::test]
async fn run_request_carries_context_pack_id_to_adapter() {
let task = "carry pack id";
let pack = pack(task);
let context_pack_id = pack.context_pack_id;
let seen = Arc::new(Mutex::new(Vec::new()));
let adapter = FixedAdapter {
seen: Arc::clone(&seen),
};
let report = run(task, &adapter, pack)
.await
.expect("runtime run succeeds");
let requests = seen.lock().expect("request mutex not poisoned");
let request = requests.first().expect("adapter saw one request");
let payload: serde_json::Value =
serde_json::from_str(&request.messages[0].content).expect("request content is JSON");
assert_eq!(requests.len(), 1);
assert_eq!(payload["context_pack_id"], json!(context_pack_id));
assert_eq!(
payload["context_pack"]["context_pack_id"],
json!(context_pack_id)
);
assert_eq!(report.prompt_hash, request.prompt_hash());
}
#[tokio::test]
async fn run_rejects_task_that_differs_from_pack_task() {
let seen = Arc::new(Mutex::new(Vec::new()));
let adapter = FixedAdapter {
seen: Arc::clone(&seen),
};
let err = run("different task", &adapter, pack("pack task"))
.await
.expect_err("task mismatch rejected");
assert!(err.to_string().contains("does not match context pack task"));
assert!(seen.lock().expect("request mutex not poisoned").is_empty());
}
#[tokio::test]
async fn remote_unsigned_mode_sets_forbidden_uses_in_event() {
let task = "remote api task";
let seen = Arc::new(Mutex::new(Vec::new()));
let adapter = FixedAdapter {
seen: Arc::clone(&seen),
};
let pack = pack(task);
let mut run_req = Run::new(task, pack).expect("valid run");
run_req.runtime_mode = RuntimeMode::RemoteUnsigned;
let report = run_configured(run_req, &adapter)
.await
.expect("remote unsigned run succeeds");
assert_eq!(report.runtime_mode, RuntimeMode::RemoteUnsigned);
let forbidden = report.agent_response_event.payload["forbidden_uses"]
.as_array()
.expect("forbidden_uses must be an array");
let names: Vec<&str> = forbidden.iter().filter_map(|v| v.as_str()).collect();
assert!(
names.contains(&"remote_unsigned"),
"ADR 0048 item 7: forbidden_uses must include \"remote_unsigned\" for RemoteUnsigned mode; got: {names:?}"
);
assert!(
names.contains(&"audit_export"),
"baseline forbidden_uses must still include \"audit_export\"; got: {names:?}"
);
}
#[tokio::test]
async fn local_unsigned_mode_does_not_set_remote_unsigned_forbidden_use() {
let task = "local task";
let seen = Arc::new(Mutex::new(Vec::new()));
let adapter = FixedAdapter {
seen: Arc::clone(&seen),
};
let report = run(task, &adapter, pack(task))
.await
.expect("local unsigned run succeeds");
let forbidden = report.agent_response_event.payload["forbidden_uses"]
.as_array()
.expect("forbidden_uses must be an array");
let names: Vec<&str> = forbidden.iter().filter_map(|v| v.as_str()).collect();
assert!(
!names.contains(&"remote_unsigned"),
"LocalUnsigned must not carry \"remote_unsigned\" in forbidden_uses; got: {names:?}"
);
}
}