Skip to main content

cortex_runtime/
lib.rs

1//! Agent runtime glue — records events; does not reinterpret memory semantics.
2#![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
24/// Schema version for runtime observability records.
25pub const RUNTIME_TRACE_SCHEMA_VERSION: u16 = 1;
26
27/// Stable operation name for child-agent runtime runs.
28pub const RUNTIME_RUN_OPERATION: &str = "runtime.run";
29
30/// Default model identifier used by runtime replay/fake adapters until CLI
31/// wiring supplies a concrete backend model.
32pub const DEFAULT_RUNTIME_MODEL: &str = "cortex-runtime-v1";
33
34/// Default timeout for one child-agent completion call.
35pub const DEFAULT_TIMEOUT_MS: u64 = 30_000;
36
37/// Runtime request for a child-agent run.
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39pub struct Run {
40    /// Cross-trace correlation id for structured run observability.
41    pub correlation_id: CorrelationId,
42    /// Operator task for the child agent.
43    pub task: String,
44    /// Auditable context pack supplied to the child agent.
45    pub pack: ContextPack,
46    /// Model requested from the adapter.
47    pub model: String,
48    /// System prompt supplied to the adapter.
49    pub system: String,
50    /// Sampling temperature.
51    pub temperature: f32,
52    /// Adapter completion token cap.
53    pub max_tokens: u32,
54    /// Adapter timeout budget.
55    pub timeout_ms: u64,
56    /// Optional session id copied onto the constructed response event.
57    pub session_id: Option<String>,
58    /// Domain tags copied onto the constructed response event.
59    pub domain_tags: Vec<String>,
60    /// Runtime mode bounding this run (ADR 0037 weakest-link).
61    ///
62    /// Defaults to `LocalUnsigned`. Callers using a remote API backend (e.g.
63    /// Claude via `LlmBackend::Claude`) MUST set this to `RemoteUnsigned`
64    /// before calling `run_configured` so that the persisted event carries the
65    /// correct `forbidden_uses` set (ADR 0048 acceptance item 7).
66    pub runtime_mode: RuntimeMode,
67}
68
69impl Run {
70    /// Build a default runtime request from a task and context pack.
71    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/// Status for a structured runtime observability record.
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
143#[serde(rename_all = "snake_case")]
144pub enum RunTraceStatus {
145    /// Runtime adapter call completed and produced a report.
146    Completed,
147}
148
149/// Minimal structured observability surface for a runtime run.
150///
151/// This intentionally carries identifiers and policy/proof metadata only. It
152/// must not grow task text, prompts, prompt hashes, raw context packs, selected
153/// refs, raw event payloads, or response text.
154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
155pub struct RunObservability {
156    /// Runtime observability schema version.
157    pub audit_schema_version: u16,
158    /// Stable operation name for routing and dashboards.
159    pub operation: String,
160    /// Status for this report-level observability record.
161    pub status: RunTraceStatus,
162    /// Cross-trace correlation id for this run.
163    pub correlation_id: CorrelationId,
164    /// Context pack id used by this run.
165    pub context_pack_id: ContextPackId,
166    /// Adapter implementation id.
167    pub adapter_id: String,
168    /// Adapter model id.
169    pub model: String,
170    /// Runtime mode bounding this report.
171    pub runtime_mode: RuntimeMode,
172    /// Proof state available for this report.
173    pub proof_state: ClaimProofState,
174    /// Effective claim ceiling for this report.
175    pub claim_ceiling: ClaimCeiling,
176    /// Whether this report has verified signed-local trusted run-history authority.
177    pub trusted_run_history: bool,
178    /// ADR 0026 policy outcome for the context pack used by this run.
179    pub context_policy_outcome: PolicyOutcome,
180    /// Hash of the adapter response body, never the response body itself.
181    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/// Non-panicking result of a runtime child-agent run.
205#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
206pub struct RunReport {
207    /// Cross-trace correlation id for structured run observability.
208    pub correlation_id: CorrelationId,
209    /// Task that was run.
210    pub task: String,
211    /// Context pack id used by the run.
212    pub context_pack_id: ContextPackId,
213    /// Minimal structured observability surface without prompt/raw context.
214    pub run_observability: RunObservability,
215    /// Adapter id that produced the response.
216    pub adapter_id: String,
217    /// Model echoed by the adapter response.
218    pub model: String,
219    /// Hash of the raw adapter response.
220    pub raw_hash: String,
221    /// Token usage echoed by the adapter, if available.
222    pub usage: Option<TokenUsage>,
223    /// Stable prompt hash of the request sent to the adapter.
224    pub prompt_hash: String,
225    /// Runtime mode bounding the run report.
226    pub runtime_mode: RuntimeMode,
227    /// Proof state available for this run report.
228    pub proof_state: ClaimProofState,
229    /// Effective claim ceiling for this run report.
230    pub claim_ceiling: ClaimCeiling,
231    /// Whether this report has verified signed-local trusted run-history authority.
232    pub trusted_run_history: bool,
233    /// Downgrade reasons explaining why stronger claims are unavailable.
234    pub downgrade_reasons: Vec<String>,
235    /// ADR 0026 policy outcome for the context pack used by this run.
236    pub context_policy_outcome: PolicyOutcome,
237    /// Constructed but unsealed agent response event for downstream ingestion.
238    pub agent_response_event: Event,
239}
240
241impl RunReport {
242    /// Rebuild the report-level observability summary after authority metadata changes.
243    pub fn refresh_observability(&mut self) {
244        self.run_observability = RunObservability::completed(self);
245    }
246}
247
248/// Runtime failures before downstream ledger/store ingestion.
249#[derive(Debug, Error)]
250pub enum RuntimeError {
251    /// Run request failed local validation.
252    #[error("validation failed: {0}")]
253    Validation(String),
254    /// LLM adapter failed.
255    #[error("llm adapter failed: {0}")]
256    Adapter(#[from] LlmError),
257}
258
259/// Run a task through an adapter using an already-built context pack.
260pub 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
268/// Run a fully configured runtime request through an adapter.
269pub 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    // ADR 0048 acceptance item 7: `EventSource::ChildAgent` events produced
371    // under `RuntimeMode::RemoteUnsigned` MUST carry `"remote_unsigned"` in
372    // their `forbidden_uses` set so downstream surfaces cannot treat a remote
373    // API response as locally-verified trusted evidence.
374    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
419/// Placeholder for `cortex run` (wire HTTP/SDK clients behind `cortex-llm`).
420pub 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    /// ADR 0048 acceptance item 7: a run executed under `RemoteUnsigned` mode
652    /// produces a `ChildAgent` event whose `forbidden_uses` array includes
653    /// `"remote_unsigned"`. This wires the guard that prevents a remote API
654    /// response from being promoted as locally-verified trusted evidence.
655    #[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    /// `LocalUnsigned` mode must NOT carry `"remote_unsigned"` in `forbidden_uses`.
688    #[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}