1use std::path::PathBuf;
45use std::sync::{Arc, Mutex};
46
47use async_trait::async_trait;
48use cortex_context::{ContextPackBuilder, ContextRefCandidate, ContextRefId, Sensitivity};
49use cortex_core::{
50 compose_policy_outcomes, AuthorityClass, ClaimCeiling, PolicyContribution, PolicyOutcome,
51 RuntimeMode,
52};
53use cortex_ledger::{
54 JsonlLog, APPEND_ATTESTATION_REQUIRED_RULE_ID, APPEND_EVENT_SOURCE_TIER_GATE_RULE_ID,
55 APPEND_RUNTIME_MODE_RULE_ID,
56};
57use cortex_llm::{
58 blake3_hex, LlmAdapter, LlmError, LlmRequest, LlmResponse, MaxSensitivity, OllamaHttpAdapter,
59};
60use cortex_retrieval::{
61 AuthorityLevel, AuthorityProofHint, ConflictingMemoryInput, ProofClosureHint,
62};
63use cortex_runtime::{run_configured, Run};
64use cortex_store::mirror::{self, MIRROR_APPEND_PARITY_INVARIANT_RULE_ID};
65use cortex_store::proof::verify_memory_proof_closure;
66use cortex_store::repo::{ContradictionRepo, MemoryRepo};
67use cortex_store::Pool;
68use tracing::warn;
69
70use crate::tool_handler::{GateId, ToolError, ToolHandler};
71
72#[derive(Debug)]
87pub struct CortexRunTool {
88 pub pool: Arc<Mutex<Pool>>,
90 pub event_log: PathBuf,
92}
93
94impl CortexRunTool {
95 #[must_use]
97 pub fn new(pool: Arc<Mutex<Pool>>, event_log: PathBuf) -> Self {
98 Self { pool, event_log }
99 }
100}
101
102impl ToolHandler for CortexRunTool {
103 fn name(&self) -> &'static str {
104 "cortex_run"
105 }
106
107 fn gate_set(&self) -> &'static [GateId] {
108 &[GateId::SessionWrite]
109 }
110
111 fn call(&self, params: serde_json::Value) -> Result<serde_json::Value, ToolError> {
112 let task = params
114 .get("task")
115 .and_then(|v| v.as_str())
116 .ok_or_else(|| {
117 ToolError::InvalidParams(
118 "required parameter `task` is missing or not a string".into(),
119 )
120 })?
121 .to_owned();
122
123 if task.trim().is_empty() {
124 return Err(ToolError::InvalidParams("`task` must not be empty".into()));
125 }
126
127 let model_override: Option<String> = params
128 .get("model")
129 .and_then(|v| v.as_str())
130 .map(|s| s.to_owned());
131
132 if let Some(ref m) = model_override {
134 if m.trim().is_empty() {
135 return Err(ToolError::InvalidParams(
136 "`model` must not be empty when supplied".into(),
137 ));
138 }
139 }
140
141 let backend = resolve_backend(model_override.as_deref());
145 let model_name = backend_model_name(&backend);
146
147 tracing::info!(
148 task = %task,
149 model = %model_name,
150 "cortex_run via MCP: dispatching task"
151 );
152
153 let pool_guard = self
155 .pool
156 .lock()
157 .map_err(|_| ToolError::Internal("pool lock poisoned".into()))?;
158
159 if let ResolvedBackend::Claude { max_sensitivity, .. } = &backend {
163 let configured_max = max_sensitivity
164 .parse::<MaxSensitivity>()
165 .unwrap_or(MaxSensitivity::Medium);
166 let repo = MemoryRepo::new(&pool_guard);
167 match repo.max_sensitivity_for_active_memories() {
168 Ok(memory_max_str) => {
169 let gate = cortex_llm::SensitivityGateResult::evaluate(
170 &memory_max_str,
171 configured_max,
172 );
173 tracing::info!(
174 max_memory_sensitivity = %gate.max_memory_sensitivity,
175 configured_max = ?gate.configured_max,
176 allowed = gate.allowed,
177 "cortex_run MCP: remote prompt domain-tag sensitivity gate"
178 );
179 if !gate.allowed {
180 return Err(ToolError::PolicyRejected(format!(
181 "sensitivity_exceeds_remote_threshold: memories at {} exceed configured max_sensitivity {}; remote dispatch refused",
182 gate.max_memory_sensitivity,
183 max_sensitivity,
184 )));
185 }
186 }
187 Err(err) => {
188 return Err(ToolError::Internal(format!(
189 "sensitivity gate store query failed: {err}; refusing remote dispatch"
190 )));
191 }
192 }
193 }
194
195 let pack = build_context_pack(&pool_guard, &task)?;
196 let context_memories_used = pack.selected_refs.len();
197
198 drop(pool_guard);
199
200 let runtime = tokio::runtime::Builder::new_current_thread()
202 .enable_all()
203 .build()
204 .map_err(|err| ToolError::Internal(format!("failed to create tokio runtime: {err}")))?;
205
206 let report = runtime.block_on(async {
207 match &backend {
208 ResolvedBackend::Offline => {
209 let adapter = MpcOfflineAdapter;
210 let mut run = Run::new(task.clone(), pack)
211 .map_err(|err| ToolError::Internal(err.to_string()))?;
212 run.model = model_name.clone();
213 run_configured(run, &adapter)
214 .await
215 .map_err(|err| ToolError::Internal(err.to_string()))
216 }
217 ResolvedBackend::Claude {
218 model,
219 max_sensitivity,
220 } => {
221 let sensitivity = max_sensitivity
222 .parse::<MaxSensitivity>()
223 .unwrap_or(MaxSensitivity::Medium);
224 match cortex_llm::ClaudeHttpAdapter::new(model.clone(), Some(sensitivity)) {
225 Ok(adapter) => {
226 let mut run = Run::new(task.clone(), pack)
227 .map_err(|err| ToolError::Internal(err.to_string()))?;
228 run.model = model.clone();
229 run.runtime_mode = RuntimeMode::RemoteUnsigned;
230 run_configured(run, &adapter)
231 .await
232 .map_err(|err| ToolError::Internal(err.to_string()))
233 }
234 Err(err) => Err(ToolError::Internal(format!(
235 "ClaudeHttpAdapter init failed: {err}"
236 ))),
237 }
238 }
239 ResolvedBackend::Ollama { endpoint, model } => {
240 use cortex_llm::OllamaConfig;
241 let config = OllamaConfig {
242 endpoint_url: endpoint.clone(),
243 model: model.clone(),
244 };
245 match OllamaHttpAdapter::new(config) {
246 Ok(adapter) => {
247 let mut run = Run::new(task.clone(), pack)
248 .map_err(|err| ToolError::Internal(err.to_string()))?;
249 run.model = model.clone();
250 run_configured(run, &adapter)
251 .await
252 .map_err(|err| ToolError::Internal(err.to_string()))
253 }
254 Err(err) => {
255 warn!(
256 "cortex_run: invalid ollama config: {err}; falling back to offline"
257 );
258 let adapter = MpcOfflineAdapter;
259 let mut run = Run::new(task.clone(), pack)
260 .map_err(|err| ToolError::Internal(err.to_string()))?;
261 run.model = model_name.clone();
262 run_configured(run, &adapter)
263 .await
264 .map_err(|err| ToolError::Internal(err.to_string()))
265 }
266 }
267 }
268 }
269 })?;
270
271 let response_text = report
273 .agent_response_event
274 .payload
275 .get("text")
276 .and_then(|v| v.as_str())
277 .unwrap_or("")
278 .to_owned();
279
280 tracing::info!(
281 task = %task,
282 model = %report.model,
283 response_len = response_text.len(),
284 raw_hash = %report.raw_hash,
285 "cortex_run via MCP: task='{}' model='{}' response_len={}",
286 task,
287 report.model,
288 response_text.len()
289 );
290
291 let session_indexed =
297 persist_agent_response_event_mcp(&self.pool, &self.event_log, &report);
298
299 Ok(serde_json::json!({
301 "task": task,
302 "model": report.model,
303 "response_text": response_text,
304 "raw_hash": report.raw_hash,
305 "persisted": session_indexed,
306 "session_indexed": session_indexed,
307 "context_memories_used": context_memories_used,
308 }))
309 }
310}
311
312fn persist_agent_response_event_mcp(
327 pool: &Arc<Mutex<Pool>>,
328 event_log: &std::path::Path,
329 report: &cortex_runtime::RunReport,
330) -> bool {
331 let ledger_policy = mcp_local_development_ledger_policy();
332 let mirror_policy = mcp_mirror_parity_satisfied_policy();
333
334 match ledger_policy.final_outcome {
336 PolicyOutcome::Allow | PolicyOutcome::Warn => {}
337 _ => {
338 tracing::info!(
339 raw_hash = %report.raw_hash,
340 runtime_mode = ?report.runtime_mode,
341 "cortex_run: ledger write skipped: policy outcome {:?} does not permit unsigned append",
342 ledger_policy.final_outcome,
343 );
344 return false;
345 }
346 }
347
348 let mut log = match JsonlLog::open(event_log) {
349 Ok(log) => log,
350 Err(err) => {
351 warn!(
352 event_log = %event_log.display(),
353 error = %err,
354 "cortex_run: failed to open JSONL event log for ledger write"
355 );
356 return false;
357 }
358 };
359
360 let mut pool_guard = match pool.lock() {
361 Ok(guard) => guard,
362 Err(_) => {
363 warn!("cortex_run: pool lock poisoned; skipping ledger write");
364 return false;
365 }
366 };
367
368 match mirror::append_event(
369 &mut log,
370 &mut pool_guard,
371 report.agent_response_event.clone(),
372 &ledger_policy,
373 &mirror_policy,
374 ) {
375 Ok(sealed) => {
376 tracing::info!(
377 event_hash = %sealed.event_hash,
378 raw_hash = %report.raw_hash,
379 "cortex_run: AgentResponse event written to JSONL ledger"
380 );
381 true
382 }
383 Err(err) => {
384 warn!(
385 error = %err,
386 raw_hash = %report.raw_hash,
387 "cortex_run: failed to persist AgentResponse event to ledger"
388 );
389 false
390 }
391 }
392}
393
394fn mcp_local_development_ledger_policy() -> cortex_core::PolicyDecision {
401 compose_policy_outcomes(
402 vec![
403 PolicyContribution::new(
404 APPEND_EVENT_SOURCE_TIER_GATE_RULE_ID,
405 PolicyOutcome::Allow,
406 "cortex_run MCP: agent-response source tier gate satisfied",
407 )
408 .expect("static policy contribution is valid"),
409 PolicyContribution::new(
410 APPEND_ATTESTATION_REQUIRED_RULE_ID,
411 PolicyOutcome::Allow,
412 "cortex_run MCP: non-user agent-response does not require user attestation",
413 )
414 .expect("static policy contribution is valid"),
415 PolicyContribution::new(
416 APPEND_RUNTIME_MODE_RULE_ID,
417 PolicyOutcome::Warn,
418 "cortex_run MCP: unsigned local-development ledger row (ADR 0037 §2 DevOnly)",
419 )
420 .expect("static policy contribution is valid"),
421 ],
422 None,
423 )
424}
425
426fn mcp_mirror_parity_satisfied_policy() -> cortex_core::PolicyDecision {
429 compose_policy_outcomes(
430 vec![PolicyContribution::new(
431 MIRROR_APPEND_PARITY_INVARIANT_RULE_ID,
432 PolicyOutcome::Allow,
433 "cortex_run MCP: mirror parity preflight passes for empty-or-consistent ledger",
434 )
435 .expect("static policy contribution is valid")],
436 None,
437 )
438}
439
440enum ResolvedBackend {
444 Offline,
445 Claude {
446 model: String,
447 max_sensitivity: String,
448 },
449 Ollama {
450 endpoint: String,
451 model: String,
452 },
453}
454
455fn resolve_backend(model_override: Option<&str>) -> ResolvedBackend {
456 if let Some(spec) = model_override {
457 if let Some(model) = spec.strip_prefix("ollama:") {
458 let endpoint = std::env::var("CORTEX_LLM_ENDPOINT")
459 .unwrap_or_else(|_| "http://localhost:11434".to_string());
460 return ResolvedBackend::Ollama {
461 endpoint,
462 model: model.to_string(),
463 };
464 }
465 if let Some(model) = spec.strip_prefix("claude:") {
466 return ResolvedBackend::Claude {
467 model: model.to_string(),
468 max_sensitivity: "medium".to_string(),
469 };
470 }
471 }
472
473 let env_backend = std::env::var("CORTEX_LLM_BACKEND")
475 .ok()
476 .filter(|s| !s.is_empty());
477 let env_model = std::env::var("CORTEX_LLM_MODEL")
478 .ok()
479 .filter(|s| !s.is_empty());
480 let env_endpoint = std::env::var("CORTEX_LLM_ENDPOINT")
481 .ok()
482 .filter(|s| !s.is_empty());
483
484 let backend_str = env_backend.as_deref().unwrap_or("offline");
485
486 match backend_str {
487 "ollama" => {
488 let model = env_model.unwrap_or_default();
489 let endpoint = env_endpoint.unwrap_or_else(|| "http://localhost:11434".to_string());
490 ResolvedBackend::Ollama { endpoint, model }
491 }
492 "claude" => {
493 let model = env_model.unwrap_or_default();
494 let max_sensitivity = std::env::var("CORTEX_LLM_MAX_SENSITIVITY")
495 .unwrap_or_else(|_| "medium".to_string());
496 ResolvedBackend::Claude {
497 model,
498 max_sensitivity,
499 }
500 }
501 _ => ResolvedBackend::Offline,
502 }
503}
504
505fn backend_model_name(backend: &ResolvedBackend) -> String {
506 match backend {
507 ResolvedBackend::Offline => "offline".to_string(),
508 ResolvedBackend::Claude { model, .. } => model.clone(),
509 ResolvedBackend::Ollama { model, .. } => format!("ollama:{model}"),
510 }
511}
512
513fn build_context_pack(pool: &Pool, task: &str) -> Result<cortex_context::ContextPack, ToolError> {
520 let repo = MemoryRepo::new(pool);
521 let active = repo
522 .list_by_status("active")
523 .map_err(|err| ToolError::Internal(format!("failed to read active memories: {err}")))?;
524
525 let mut builder = ContextPackBuilder::new(task, 4096_usize);
526
527 for memory in &active {
528 let proof = verify_memory_proof_closure(pool, &memory.id).map_err(|err| {
529 ToolError::Internal(format!(
530 "failed to verify memory {} proof closure: {err}",
531 memory.id
532 ))
533 })?;
534 if proof.require_current_use_allowed().is_err() {
535 continue;
537 }
538 builder = builder.select_ref(
539 ContextRefCandidate::new(
540 ContextRefId::Memory {
541 memory_id: memory.id,
542 },
543 memory.claim.clone(),
544 )
545 .with_claim_metadata(
546 RuntimeMode::LocalUnsigned,
547 AuthorityClass::Derived,
548 proof.state().into(),
549 ClaimCeiling::LocalUnsigned,
550 )
551 .with_sensitivity(Sensitivity::Internal),
552 );
553 }
554
555 gate_open_contradictions(pool, &active)?;
557
558 builder
559 .build()
560 .map_err(|err| ToolError::Internal(format!("context pack build failed: {err}")))
561}
562
563fn gate_open_contradictions(
567 pool: &Pool,
568 memories: &[cortex_store::repo::MemoryRecord],
569) -> Result<(), ToolError> {
570 use cortex_retrieval::resolve_conflicts;
571 use std::collections::{BTreeMap, BTreeSet};
572
573 let active_by_id: BTreeMap<String, &cortex_store::repo::MemoryRecord> =
574 memories.iter().map(|m| (m.id.to_string(), m)).collect();
575
576 let contradictions = ContradictionRepo::new(pool)
577 .list_open()
578 .map_err(|err| ToolError::Internal(format!("failed to read open contradictions: {err}")))?;
579
580 let mut affected_ids = BTreeSet::new();
581 let mut conflict_edges = BTreeMap::<String, BTreeSet<String>>::new();
582
583 for contradiction in contradictions {
584 let left_active = active_by_id.contains_key(&contradiction.left_ref);
585 let right_active = active_by_id.contains_key(&contradiction.right_ref);
586 if !left_active && !right_active {
587 continue;
588 }
589 if !(left_active && right_active) {
590 return Err(ToolError::PolicyRejected(format!(
591 "open contradiction {} references unavailable memory and cannot be resolved for default context-pack use",
592 contradiction.id
593 )));
594 }
595 affected_ids.insert(contradiction.left_ref.clone());
596 affected_ids.insert(contradiction.right_ref.clone());
597 conflict_edges
598 .entry(contradiction.left_ref.clone())
599 .or_default()
600 .insert(contradiction.right_ref.clone());
601 conflict_edges
602 .entry(contradiction.right_ref)
603 .or_default()
604 .insert(contradiction.left_ref);
605 }
606
607 if affected_ids.is_empty() {
608 return Ok(());
609 }
610
611 let inputs = affected_ids
612 .iter()
613 .filter_map(|id| active_by_id.get(id.as_str()).copied())
614 .map(|memory| {
615 ConflictingMemoryInput::new(
616 memory.id.to_string(),
617 Some(memory.id.to_string()),
618 memory.claim.clone(),
619 AuthorityProofHint {
620 authority: authority_level(&memory.authority),
621 proof: ProofClosureHint::FullChainVerified,
622 },
623 )
624 .with_conflicts(
625 conflict_edges
626 .get(&memory.id.to_string())
627 .map(|ids| ids.iter().cloned().collect())
628 .unwrap_or_default(),
629 )
630 })
631 .collect::<Vec<_>>();
632
633 let output = resolve_conflicts(&inputs, &[]);
634 output.require_default_use_allowed().map_err(|err| {
635 ToolError::PolicyRejected(format!(
636 "open contradiction blocks default context-pack use: {err}"
637 ))
638 })
639}
640
641fn authority_level(authority: &str) -> AuthorityLevel {
642 match authority {
643 "user" | "operator" => AuthorityLevel::High,
644 "tool" | "system" => AuthorityLevel::Medium,
645 _ => AuthorityLevel::Low,
646 }
647}
648
649#[derive(Debug)]
652struct MpcOfflineAdapter;
653
654#[async_trait]
655impl LlmAdapter for MpcOfflineAdapter {
656 fn adapter_id(&self) -> &'static str {
657 "mcp-offline"
658 }
659
660 async fn complete(&self, req: LlmRequest) -> Result<LlmResponse, LlmError> {
661 let text = format!("offline response for {}", req.model);
662 Ok(LlmResponse {
663 text: text.clone(),
664 parsed_json: None,
665 model: req.model,
666 usage: None,
667 raw_hash: blake3_hex(text.as_bytes()),
668 })
669 }
670}
671
672#[cfg(test)]
673mod tests {
674 use super::*;
675
676 fn make_pool() -> Arc<Mutex<Pool>> {
677 let pool = cortex_store::Pool::open_in_memory().expect("in-memory sqlite");
678 cortex_store::migrate::apply_pending(&pool).expect("in-memory migrations");
679 Arc::new(Mutex::new(pool))
680 }
681
682 fn make_tool_with_log(event_log: PathBuf) -> CortexRunTool {
683 CortexRunTool::new(make_pool(), event_log)
684 }
685
686 fn make_tool() -> (CortexRunTool, tempfile::TempDir) {
687 let dir = tempfile::tempdir().expect("temp dir");
688 let log_path = dir.path().join("events.jsonl");
689 let tool = make_tool_with_log(log_path);
690 (tool, dir)
691 }
692
693 #[test]
695 fn tool_name_matches_schema_contract() {
696 let (tool, _dir) = make_tool();
697 assert_eq!(tool.name(), "cortex_run");
698 }
699
700 #[test]
702 fn gate_set_declares_session_write() {
703 let (tool, _dir) = make_tool();
704 assert!(
705 tool.gate_set().contains(&GateId::SessionWrite),
706 "gate_set must include SessionWrite"
707 );
708 }
709
710 #[test]
712 fn missing_task_returns_invalid_params() {
713 let (tool, _dir) = make_tool();
714 let err = tool
715 .call(serde_json::json!({}))
716 .expect_err("must reject missing task");
717 assert!(
718 matches!(err, ToolError::InvalidParams(_)),
719 "expected InvalidParams, got: {err:?}"
720 );
721 }
722
723 #[test]
725 fn empty_task_returns_invalid_params() {
726 let (tool, _dir) = make_tool();
727 let err = tool
728 .call(serde_json::json!({ "task": " " }))
729 .expect_err("must reject empty task");
730 assert!(
731 matches!(err, ToolError::InvalidParams(_)),
732 "expected InvalidParams, got: {err:?}"
733 );
734 }
735
736 #[test]
738 fn empty_model_override_returns_invalid_params() {
739 let (tool, _dir) = make_tool();
740 let err = tool
741 .call(serde_json::json!({ "task": "hello", "model": "" }))
742 .expect_err("must reject empty model");
743 assert!(
744 matches!(err, ToolError::InvalidParams(_)),
745 "expected InvalidParams, got: {err:?}"
746 );
747 }
748
749 #[test]
752 fn valid_task_writes_event_to_ledger() {
753 let dir = tempfile::tempdir().expect("temp dir");
754 let log_path = dir.path().join("events.jsonl");
755 let tool = make_tool_with_log(log_path.clone());
756
757 let result = tool
758 .call(serde_json::json!({ "task": "diagnose the system" }))
759 .expect("offline run must succeed");
760
761 assert_eq!(result["task"], "diagnose the system");
762 assert!(result["raw_hash"].as_str().is_some());
763 assert!(result["response_text"].as_str().is_some());
764 assert!(result["model"].as_str().is_some());
765
766 let session_indexed = result["session_indexed"]
768 .as_bool()
769 .expect("session_indexed must be a boolean");
770 assert_eq!(result["persisted"], result["session_indexed"]);
772
773 if session_indexed {
774 let log =
776 cortex_ledger::JsonlLog::open(&log_path).expect("JSONL log opens after tool call");
777 assert_eq!(
778 log.len(),
779 1,
780 "one AgentResponse event must be in the JSONL log"
781 );
782 }
783 }
784
785 #[test]
787 fn context_memories_used_is_zero_for_empty_store() {
788 let (tool, _dir) = make_tool();
789
790 let result = tool
791 .call(serde_json::json!({ "task": "summarise project status" }))
792 .expect("offline run must succeed");
793
794 let used = result["context_memories_used"]
795 .as_u64()
796 .expect("context_memories_used must be a non-negative integer");
797 assert_eq!(used, 0, "empty store produces no context memories");
798 }
799
800 #[test]
807 fn context_memories_used_reflects_active_memory_count_and_pack_is_non_empty() {
808 let pool_inner = cortex_store::Pool::open_in_memory().expect("in-memory sqlite");
809 cortex_store::migrate::apply_pending(&pool_inner).expect("apply migrations");
810
811 let event_id = cortex_core::EventId::new().to_string();
815 let memory_id = cortex_core::MemoryId::new().to_string();
816 pool_inner
817 .execute(
818 "INSERT INTO events (
819 id, schema_version, observed_at, recorded_at, source_json,
820 event_type, trace_id, session_id, domain_tags_json, payload_json,
821 payload_hash, prev_event_hash, event_hash
822 ) VALUES (
823 ?1, 1, '2026-05-13T00:00:00Z', '2026-05-13T00:00:00Z',
824 '{\"type\":\"tool\",\"name\":\"test\"}', 'cortex.event.tool_result.v1',
825 NULL, NULL, '[]', '{\"fixture\":true}',
826 'pp_test', NULL, 'eh_test'
827 );",
828 rusqlite::params![event_id],
829 )
830 .expect("insert source event fixture");
831 let source_events_json = serde_json::json!([event_id]).to_string();
832 pool_inner
833 .execute(
834 "INSERT INTO memories (
835 id, memory_type, status, claim, source_episodes_json,
836 source_events_json, domains_json, salience_json, confidence,
837 authority, applies_when_json, does_not_apply_when_json,
838 created_at, updated_at
839 ) VALUES (
840 ?1, 'semantic', 'active',
841 'Cortex injects memory context into every LLM request.',
842 '[]', ?2, '[]',
843 json_object('score', 0.9), 0.9, 'user',
844 '[]', '[]',
845 '2026-05-13T00:00:00Z', '2026-05-13T00:00:00Z'
846 );",
847 rusqlite::params![memory_id, source_events_json],
848 )
849 .expect("insert active memory fixture");
850
851 let dir = tempfile::tempdir().expect("temp dir");
852 let log_path = dir.path().join("events.jsonl");
853 let pool = Arc::new(Mutex::new(pool_inner));
854 let tool = CortexRunTool::new(Arc::clone(&pool), log_path);
855
856 let result = tool
857 .call(serde_json::json!({ "task": "summarise project status" }))
858 .expect("offline run with seeded memories must succeed");
859
860 let used = result["context_memories_used"]
861 .as_u64()
862 .expect("context_memories_used must be a non-negative integer");
863 assert!(
864 used >= 1,
865 "one active memory must produce context_memories_used >= 1; got {used}"
866 );
867 }
868}