1#![warn(missing_docs)]
3
4use chrono::Utc;
5use cortex_context::ContextPack;
6use cortex_core::{
7 AuthorityClass, ClaimCeiling, ClaimProofState, ContextPackId, CorrelationId, CortexResult,
8 Event, EventId, EventSource, EventType, PolicyOutcome, RuntimeMode, SCHEMA_VERSION,
9};
10use cortex_llm::{LlmAdapter, LlmError, LlmMessage, LlmRequest, LlmResponse, LlmRole, TokenUsage};
11use serde::{Deserialize, Serialize};
12use serde_json::json;
13use thiserror::Error;
14
15pub mod claims;
16
17pub use claims::{
18 compile_runtime_claim, development_ledger_use_decision, require_runtime_claim,
19 require_runtime_claim_with_policy, runtime_claim_preflight,
20 runtime_claim_preflight_with_policy, CompiledRuntimeClaim, DevelopmentLedgerUse,
21 DevelopmentLedgerUseDecision, RuntimeClaimKind, RuntimeClaimPreflight,
22};
23
24pub const RUNTIME_TRACE_SCHEMA_VERSION: u16 = 1;
26
27pub const RUNTIME_RUN_OPERATION: &str = "runtime.run";
29
30pub const DEFAULT_RUNTIME_MODEL: &str = "cortex-runtime-v1";
33
34pub const DEFAULT_TIMEOUT_MS: u64 = 30_000;
36
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39pub struct Run {
40 pub correlation_id: CorrelationId,
42 pub task: String,
44 pub pack: ContextPack,
46 pub model: String,
48 pub system: String,
50 pub temperature: f32,
52 pub max_tokens: u32,
54 pub timeout_ms: u64,
56 pub session_id: Option<String>,
58 pub domain_tags: Vec<String>,
60 pub runtime_mode: RuntimeMode,
67}
68
69impl Run {
70 pub fn new(task: impl Into<String>, pack: ContextPack) -> Result<Self, RuntimeError> {
72 let task = task.into();
73 let max_tokens = u32::try_from(pack.max_tokens).map_err(|_| {
74 RuntimeError::Validation(format!(
75 "context pack max_tokens {} exceeds u32::MAX",
76 pack.max_tokens
77 ))
78 })?;
79
80 Ok(Self {
81 correlation_id: CorrelationId::new(),
82 task,
83 pack,
84 model: DEFAULT_RUNTIME_MODEL.to_string(),
85 system: "You are a Cortex child agent. Use the supplied ContextPack as declared context; do not infer hidden memory.".to_string(),
86 temperature: 0.0,
87 max_tokens,
88 timeout_ms: DEFAULT_TIMEOUT_MS,
89 session_id: None,
90 domain_tags: vec!["runtime".to_string()],
91 runtime_mode: RuntimeMode::LocalUnsigned,
92 })
93 }
94
95 fn validate(&self) -> Result<(), RuntimeError> {
96 if self.task.trim().is_empty() {
97 return Err(RuntimeError::Validation(
98 "runtime task must not be empty".to_string(),
99 ));
100 }
101 if self.pack.task != self.task {
102 return Err(RuntimeError::Validation(format!(
103 "runtime task `{}` does not match context pack task `{}`",
104 self.task, self.pack.task
105 )));
106 }
107 if self.model.trim().is_empty() {
108 return Err(RuntimeError::Validation(
109 "runtime model must not be empty".to_string(),
110 ));
111 }
112 if self.max_tokens == 0 {
113 return Err(RuntimeError::Validation(
114 "runtime max_tokens must be greater than zero".to_string(),
115 ));
116 }
117 Ok(())
118 }
119
120 fn llm_request(&self) -> LlmRequest {
121 LlmRequest {
122 model: self.model.clone(),
123 system: self.system.clone(),
124 messages: vec![LlmMessage {
125 role: LlmRole::User,
126 content: json!({
127 "task": self.task,
128 "context_pack_id": self.pack.context_pack_id,
129 "context_pack": self.pack,
130 })
131 .to_string(),
132 }],
133 temperature: self.temperature,
134 max_tokens: self.max_tokens,
135 json_schema: None,
136 timeout_ms: self.timeout_ms,
137 }
138 }
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
143#[serde(rename_all = "snake_case")]
144pub enum RunTraceStatus {
145 Completed,
147}
148
149#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
155pub struct RunObservability {
156 pub audit_schema_version: u16,
158 pub operation: String,
160 pub status: RunTraceStatus,
162 pub correlation_id: CorrelationId,
164 pub context_pack_id: ContextPackId,
166 pub adapter_id: String,
168 pub model: String,
170 pub runtime_mode: RuntimeMode,
172 pub proof_state: ClaimProofState,
174 pub claim_ceiling: ClaimCeiling,
176 pub trusted_run_history: bool,
178 pub context_policy_outcome: PolicyOutcome,
180 pub response_hash: String,
182}
183
184impl RunObservability {
185 fn completed(report: &RunReport) -> Self {
186 Self {
187 audit_schema_version: RUNTIME_TRACE_SCHEMA_VERSION,
188 operation: RUNTIME_RUN_OPERATION.to_string(),
189 status: RunTraceStatus::Completed,
190 correlation_id: report.correlation_id,
191 context_pack_id: report.context_pack_id,
192 adapter_id: report.adapter_id.clone(),
193 model: report.model.clone(),
194 runtime_mode: report.runtime_mode,
195 proof_state: report.proof_state,
196 claim_ceiling: report.claim_ceiling,
197 trusted_run_history: report.trusted_run_history,
198 context_policy_outcome: report.context_policy_outcome,
199 response_hash: report.raw_hash.clone(),
200 }
201 }
202}
203
204#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
206pub struct RunReport {
207 pub correlation_id: CorrelationId,
209 pub task: String,
211 pub context_pack_id: ContextPackId,
213 pub run_observability: RunObservability,
215 pub adapter_id: String,
217 pub model: String,
219 pub raw_hash: String,
221 pub usage: Option<TokenUsage>,
223 pub prompt_hash: String,
225 pub runtime_mode: RuntimeMode,
227 pub proof_state: ClaimProofState,
229 pub claim_ceiling: ClaimCeiling,
231 pub trusted_run_history: bool,
233 pub downgrade_reasons: Vec<String>,
235 pub context_policy_outcome: PolicyOutcome,
237 pub agent_response_event: Event,
239}
240
241impl RunReport {
242 pub fn refresh_observability(&mut self) {
244 self.run_observability = RunObservability::completed(self);
245 }
246}
247
248#[derive(Debug, Error)]
250pub enum RuntimeError {
251 #[error("validation failed: {0}")]
253 Validation(String),
254 #[error("llm adapter failed: {0}")]
256 Adapter(#[from] LlmError),
257}
258
259pub async fn run(
261 task: impl Into<String>,
262 adapter: &dyn LlmAdapter,
263 pack: ContextPack,
264) -> Result<RunReport, RuntimeError> {
265 run_configured(Run::new(task, pack)?, adapter).await
266}
267
268pub async fn run_configured(run: Run, adapter: &dyn LlmAdapter) -> Result<RunReport, RuntimeError> {
270 run.validate()?;
271 tracing::info!(
272 audit_schema_version = RUNTIME_TRACE_SCHEMA_VERSION,
273 operation = RUNTIME_RUN_OPERATION,
274 correlation_id = %run.correlation_id,
275 context_pack_id = %run.pack.context_pack_id,
276 adapter_id = adapter.adapter_id(),
277 model = %run.model,
278 status = "started",
279 "runtime run"
280 );
281 let request = run.llm_request();
282 let prompt_hash = request.prompt_hash();
283 let response = match adapter.complete(request).await {
284 Ok(response) => response,
285 Err(err) => {
286 tracing::warn!(
287 audit_schema_version = RUNTIME_TRACE_SCHEMA_VERSION,
288 operation = RUNTIME_RUN_OPERATION,
289 correlation_id = %run.correlation_id,
290 context_pack_id = %run.pack.context_pack_id,
291 adapter_id = adapter.adapter_id(),
292 model = %run.model,
293 status = "failed",
294 error_kind = "adapter",
295 "runtime run"
296 );
297 return Err(err.into());
298 }
299 };
300 let adapter_id = adapter.adapter_id().to_string();
301 let runtime_mode = run.runtime_mode;
302 let event = agent_response_event(&run, &adapter_id, &response, runtime_mode);
303 let context_policy = run.pack.policy_decision();
304 let claim = claims::compile_runtime_claim(
305 "development ledger run output",
306 claims::RuntimeClaimKind::Advisory,
307 runtime_mode,
308 AuthorityClass::Observed,
309 ClaimProofState::Partial,
310 runtime_mode.claim_ceiling(),
311 );
312
313 let correlation_id = run.correlation_id;
314 let context_pack_id = run.pack.context_pack_id;
315 tracing::info!(
316 audit_schema_version = RUNTIME_TRACE_SCHEMA_VERSION,
317 operation = RUNTIME_RUN_OPERATION,
318 correlation_id = %correlation_id,
319 context_pack_id = %context_pack_id,
320 adapter_id = %adapter_id,
321 model = %response.model,
322 status = "completed",
323 context_policy_outcome = ?context_policy.final_outcome,
324 response_hash = %response.raw_hash,
325 "runtime run"
326 );
327
328 let mut report = RunReport {
329 correlation_id,
330 task: run.task,
331 context_pack_id,
332 run_observability: RunObservability {
333 audit_schema_version: RUNTIME_TRACE_SCHEMA_VERSION,
334 operation: RUNTIME_RUN_OPERATION.to_string(),
335 status: RunTraceStatus::Completed,
336 correlation_id,
337 context_pack_id,
338 adapter_id: adapter_id.clone(),
339 model: response.model.clone(),
340 runtime_mode: claim.runtime_mode,
341 proof_state: claim.proof_state,
342 claim_ceiling: claim.effective_ceiling,
343 trusted_run_history: false,
344 context_policy_outcome: context_policy.final_outcome,
345 response_hash: response.raw_hash.clone(),
346 },
347 adapter_id,
348 model: response.model,
349 raw_hash: response.raw_hash,
350 usage: response.usage,
351 prompt_hash,
352 runtime_mode: claim.runtime_mode,
353 proof_state: claim.proof_state,
354 claim_ceiling: claim.effective_ceiling,
355 trusted_run_history: false,
356 downgrade_reasons: claim.reasons,
357 context_policy_outcome: context_policy.final_outcome,
358 agent_response_event: event,
359 };
360 report.refresh_observability();
361 Ok(report)
362}
363
364fn agent_response_event(
365 run: &Run,
366 adapter_id: &str,
367 response: &LlmResponse,
368 runtime_mode: RuntimeMode,
369) -> Event {
370 let mut forbidden_uses = vec![
375 "audit_export",
376 "compliance_evidence",
377 "cross_system_trust_decision",
378 "external_reporting",
379 ];
380 if runtime_mode == RuntimeMode::RemoteUnsigned {
381 forbidden_uses.push("remote_unsigned");
382 }
383
384 let now = Utc::now();
385 Event {
386 id: EventId::new(),
387 schema_version: SCHEMA_VERSION,
388 observed_at: now,
389 recorded_at: now,
390 source: EventSource::ChildAgent {
391 model: response.model.clone(),
392 },
393 event_type: EventType::AgentResponse,
394 trace_id: None,
395 session_id: run.session_id.clone(),
396 domain_tags: run.domain_tags.clone(),
397 payload: json!({
398 "task": run.task,
399 "correlation_id": run.correlation_id,
400 "context_pack_id": run.pack.context_pack_id,
401 "adapter_id": adapter_id,
402 "model": response.model,
403 "text": response.text,
404 "parsed_json": response.parsed_json,
405 "raw_hash": response.raw_hash,
406 "usage": response.usage,
407 "runtime_mode": runtime_mode,
408 "ledger_authority": "development",
409 "signed_ledger_authority": false,
410 "trusted_run_history": false,
411 "forbidden_uses": forbidden_uses,
412 }),
413 payload_hash: String::new(),
414 prev_event_hash: None,
415 event_hash: String::new(),
416 }
417}
418
419pub fn run_stub(adapter: &dyn LlmAdapter) -> CortexResult<()> {
421 tracing::info!(adapter = adapter.adapter_id(), "run stub");
422 Ok(())
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428 use async_trait::async_trait;
429 use cortex_context::ContextPackBuilder;
430 use cortex_llm::blake3_hex;
431 use std::sync::{Arc, Mutex};
432
433 #[derive(Debug)]
434 struct FixedAdapter {
435 seen: Arc<Mutex<Vec<LlmRequest>>>,
436 }
437
438 #[async_trait]
439 impl LlmAdapter for FixedAdapter {
440 fn adapter_id(&self) -> &'static str {
441 "fixed-runtime"
442 }
443
444 async fn complete(&self, req: LlmRequest) -> Result<LlmResponse, LlmError> {
445 self.seen
446 .lock()
447 .expect("request mutex not poisoned")
448 .push(req);
449 let text = "runtime response".to_string();
450 Ok(LlmResponse {
451 text: text.clone(),
452 parsed_json: None,
453 model: DEFAULT_RUNTIME_MODEL.to_string(),
454 usage: Some(TokenUsage {
455 prompt_tokens: 12,
456 completion_tokens: 3,
457 }),
458 raw_hash: blake3_hex(text.as_bytes()),
459 })
460 }
461 }
462
463 fn pack(task: &str) -> ContextPack {
464 ContextPackBuilder::new(task, 512)
465 .build()
466 .expect("valid empty context pack")
467 }
468
469 #[tokio::test]
470 async fn run_builds_agent_response_event_linked_to_pack() {
471 let task = "answer with declared context";
472 let pack = pack(task);
473 let context_pack_id = pack.context_pack_id;
474 let seen = Arc::new(Mutex::new(Vec::new()));
475 let adapter = FixedAdapter {
476 seen: Arc::clone(&seen),
477 };
478
479 let report = run(task, &adapter, pack)
480 .await
481 .expect("runtime run succeeds");
482
483 assert_eq!(report.context_pack_id, context_pack_id);
484 assert!(report.correlation_id.to_string().starts_with("cor_"));
485 assert_eq!(
486 report.run_observability.correlation_id,
487 report.correlation_id
488 );
489 assert_eq!(report.run_observability.context_pack_id, context_pack_id);
490 assert_eq!(
491 report.run_observability.audit_schema_version,
492 RUNTIME_TRACE_SCHEMA_VERSION
493 );
494 assert_eq!(report.run_observability.operation, RUNTIME_RUN_OPERATION);
495 assert_eq!(report.run_observability.status, RunTraceStatus::Completed);
496 assert_eq!(report.run_observability.adapter_id, "fixed-runtime");
497 assert_eq!(report.run_observability.model, DEFAULT_RUNTIME_MODEL);
498 assert_eq!(
499 report.run_observability.runtime_mode,
500 RuntimeMode::LocalUnsigned
501 );
502 assert_eq!(
503 report.run_observability.proof_state,
504 ClaimProofState::Partial
505 );
506 assert_eq!(
507 report.run_observability.claim_ceiling,
508 ClaimCeiling::LocalUnsigned
509 );
510 assert!(!report.run_observability.trusted_run_history);
511 assert_eq!(
512 report.run_observability.context_policy_outcome,
513 PolicyOutcome::Allow
514 );
515 assert_eq!(report.run_observability.response_hash, report.raw_hash);
516 assert_eq!(report.adapter_id, "fixed-runtime");
517 assert_eq!(
518 report.agent_response_event.event_type,
519 EventType::AgentResponse
520 );
521 assert_eq!(
522 report.agent_response_event.payload["correlation_id"],
523 json!(report.correlation_id)
524 );
525 assert_eq!(
526 report.agent_response_event.payload["context_pack_id"],
527 json!(context_pack_id)
528 );
529 assert_eq!(
530 report.agent_response_event.payload["ledger_authority"],
531 json!("development")
532 );
533 assert_eq!(
534 report.agent_response_event.payload["signed_ledger_authority"],
535 json!(false)
536 );
537 assert_eq!(report.runtime_mode, RuntimeMode::LocalUnsigned);
538 assert_eq!(report.proof_state, ClaimProofState::Partial);
539 assert_eq!(report.claim_ceiling, ClaimCeiling::LocalUnsigned);
540 assert!(!report.trusted_run_history);
541 assert_eq!(report.context_policy_outcome, PolicyOutcome::Allow);
542 assert!(report
543 .downgrade_reasons
544 .iter()
545 .any(|reason| reason.contains("proof state Partial")));
546 assert!(report.agent_response_event.payload["forbidden_uses"]
547 .as_array()
548 .expect("forbidden_uses array")
549 .iter()
550 .any(|value| value.as_str() == Some("audit_export")));
551 assert_eq!(
552 report.agent_response_event.source,
553 EventSource::ChildAgent {
554 model: DEFAULT_RUNTIME_MODEL.to_string()
555 }
556 );
557 assert_eq!(report.agent_response_event.payload_hash, "");
558 assert_eq!(report.agent_response_event.event_hash, "");
559 }
560
561 #[tokio::test]
562 async fn run_observability_excludes_prompt_and_raw_context() {
563 let task = "observe without leaking prompt";
564 let seen = Arc::new(Mutex::new(Vec::new()));
565 let adapter = FixedAdapter {
566 seen: Arc::clone(&seen),
567 };
568
569 let report = run(task, &adapter, pack(task))
570 .await
571 .expect("runtime run succeeds");
572 let observability =
573 serde_json::to_value(&report.run_observability).expect("observability serializes");
574
575 assert_eq!(
576 observability["correlation_id"],
577 json!(report.correlation_id)
578 );
579 assert_eq!(
580 observability["context_pack_id"],
581 json!(report.context_pack_id)
582 );
583 for forbidden_key in [
584 "task",
585 "system",
586 "messages",
587 "prompt",
588 "prompt_hash",
589 "context_pack",
590 "selected_refs",
591 "raw_context",
592 "raw_event_payload",
593 "response_text",
594 "text",
595 ] {
596 assert!(
597 observability.get(forbidden_key).is_none(),
598 "run observability must not expose {forbidden_key}"
599 );
600 }
601 let serialized =
602 serde_json::to_string(&observability).expect("observability serializes to string");
603 assert!(
604 !serialized.contains(task),
605 "run observability must not include task text"
606 );
607 }
608
609 #[tokio::test]
610 async fn run_request_carries_context_pack_id_to_adapter() {
611 let task = "carry pack id";
612 let pack = pack(task);
613 let context_pack_id = pack.context_pack_id;
614 let seen = Arc::new(Mutex::new(Vec::new()));
615 let adapter = FixedAdapter {
616 seen: Arc::clone(&seen),
617 };
618
619 let report = run(task, &adapter, pack)
620 .await
621 .expect("runtime run succeeds");
622 let requests = seen.lock().expect("request mutex not poisoned");
623 let request = requests.first().expect("adapter saw one request");
624 let payload: serde_json::Value =
625 serde_json::from_str(&request.messages[0].content).expect("request content is JSON");
626
627 assert_eq!(requests.len(), 1);
628 assert_eq!(payload["context_pack_id"], json!(context_pack_id));
629 assert_eq!(
630 payload["context_pack"]["context_pack_id"],
631 json!(context_pack_id)
632 );
633 assert_eq!(report.prompt_hash, request.prompt_hash());
634 }
635
636 #[tokio::test]
637 async fn run_rejects_task_that_differs_from_pack_task() {
638 let seen = Arc::new(Mutex::new(Vec::new()));
639 let adapter = FixedAdapter {
640 seen: Arc::clone(&seen),
641 };
642
643 let err = run("different task", &adapter, pack("pack task"))
644 .await
645 .expect_err("task mismatch rejected");
646
647 assert!(err.to_string().contains("does not match context pack task"));
648 assert!(seen.lock().expect("request mutex not poisoned").is_empty());
649 }
650
651 #[tokio::test]
656 async fn remote_unsigned_mode_sets_forbidden_uses_in_event() {
657 let task = "remote api task";
658 let seen = Arc::new(Mutex::new(Vec::new()));
659 let adapter = FixedAdapter {
660 seen: Arc::clone(&seen),
661 };
662 let pack = pack(task);
663 let mut run_req = Run::new(task, pack).expect("valid run");
664 run_req.runtime_mode = RuntimeMode::RemoteUnsigned;
665
666 let report = run_configured(run_req, &adapter)
667 .await
668 .expect("remote unsigned run succeeds");
669
670 assert_eq!(report.runtime_mode, RuntimeMode::RemoteUnsigned);
671
672 let forbidden = report.agent_response_event.payload["forbidden_uses"]
673 .as_array()
674 .expect("forbidden_uses must be an array");
675 let names: Vec<&str> = forbidden.iter().filter_map(|v| v.as_str()).collect();
676
677 assert!(
678 names.contains(&"remote_unsigned"),
679 "ADR 0048 item 7: forbidden_uses must include \"remote_unsigned\" for RemoteUnsigned mode; got: {names:?}"
680 );
681 assert!(
682 names.contains(&"audit_export"),
683 "baseline forbidden_uses must still include \"audit_export\"; got: {names:?}"
684 );
685 }
686
687 #[tokio::test]
689 async fn local_unsigned_mode_does_not_set_remote_unsigned_forbidden_use() {
690 let task = "local task";
691 let seen = Arc::new(Mutex::new(Vec::new()));
692 let adapter = FixedAdapter {
693 seen: Arc::clone(&seen),
694 };
695
696 let report = run(task, &adapter, pack(task))
697 .await
698 .expect("local unsigned run succeeds");
699
700 let forbidden = report.agent_response_event.payload["forbidden_uses"]
701 .as_array()
702 .expect("forbidden_uses must be an array");
703 let names: Vec<&str> = forbidden.iter().filter_map(|v| v.as_str()).collect();
704
705 assert!(
706 !names.contains(&"remote_unsigned"),
707 "LocalUnsigned must not carry \"remote_unsigned\" in forbidden_uses; got: {names:?}"
708 );
709 }
710}