Skip to main content

harness_codex/
app_server_runtime.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3//! Pure, injected JSONL state machine for the Codex whole-agent app-server seam.
4//!
5//! This module does not own process creation or authentication. Callers supply
6//! complete transcript lines and transport the returned request lines.
7
8use crate::app_server::CURRENT_STANDALONE_CLI_VERSION;
9use serde::{Deserialize, Serialize};
10use serde_json::{Value, json};
11use sha2::{Digest, Sha256};
12use std::collections::{BTreeMap, BTreeSet};
13use std::fmt;
14use std::path::{Component, Path};
15use thiserror::Error;
16
17const MAX_LINE_BYTES: usize = 256 * 1024;
18const MAX_TEXT_BYTES: usize = 64 * 1024;
19const MAX_OUTPUT_BYTES: usize = 256 * 1024;
20const MAX_ID_BYTES: usize = 128;
21const MODEL_PAGE_LIMIT: usize = 50;
22const MAX_MODELS: usize = 256;
23const MAX_PENDING: usize = 64;
24const MAX_THREADS: usize = 64;
25const MAX_TURNS: usize = 64;
26const MAX_ITEMS_PER_TURN: usize = 256;
27const MAX_THREAD_PATH_BYTES: usize = 4 * 1024;
28
29const TOKEN_KEYS: [&str; 9] = [
30    "access_token",
31    "accesstoken",
32    "refresh_token",
33    "refreshtoken",
34    "id_token",
35    "idtoken",
36    "authorization",
37    "api_key",
38    "apikey",
39];
40
41#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
42#[serde(rename_all = "lowercase")]
43pub enum ReasoningEffort {
44    Minimal,
45    Low,
46    Medium,
47    High,
48    Xhigh,
49    Max,
50    Ultra,
51}
52
53#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "lowercase")]
55pub enum CodexRole {
56    Luna,
57    Terra,
58    Sol,
59}
60
61#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62pub struct CodexRoleProjection {
63    pub role: CodexRole,
64    pub task_class: &'static str,
65    pub escalates_to: Option<CodexRole>,
66    pub escalation_reason: Option<&'static str>,
67}
68
69pub const OPENAI_ROLE_PROJECTIONS: [CodexRoleProjection; 3] = [
70    CodexRoleProjection {
71        role: CodexRole::Luna,
72        task_class: "routine",
73        escalates_to: Some(CodexRole::Terra),
74        escalation_reason: Some("qualifying correctness, format, or capability failure"),
75    },
76    CodexRoleProjection {
77        role: CodexRole::Terra,
78        task_class: "standard",
79        escalates_to: Some(CodexRole::Sol),
80        escalation_reason: Some("qualifying correctness, format, or capability failure"),
81    },
82    CodexRoleProjection {
83        role: CodexRole::Sol,
84        task_class: "complex_or_high_risk",
85        escalates_to: None,
86        escalation_reason: None,
87    },
88];
89
90#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
91pub struct ManagedAccountProjection {
92    pub plan_type: Option<String>,
93    pub requires_openai_auth: bool,
94}
95
96#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
97pub struct ModelProjection {
98    pub id: String,
99    pub display_name: String,
100    pub supported_efforts: Vec<ReasoningEffort>,
101}
102
103#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
104pub struct ModelPageProjection {
105    pub models: Vec<ModelProjection>,
106    pub next_cursor: Option<String>,
107}
108
109#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
110pub struct RateLimitWindowProjection {
111    pub used_percent: u8,
112    pub window_duration_minutes: Option<u64>,
113    pub resets_at_unix_seconds: Option<u64>,
114}
115
116#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
117pub struct RateLimitsProjection {
118    pub primary: Option<RateLimitWindowProjection>,
119    pub secondary: Option<RateLimitWindowProjection>,
120    pub reached_type: Option<String>,
121}
122
123#[derive(Clone, Copy, Debug, PartialEq, Eq)]
124pub enum ReplayDisposition {
125    EligibleBeforeVisibility,
126    DeniedVisibleOutput,
127    DeniedToolOrSideEffect,
128    DeniedModelReroute,
129}
130
131/// Explicit handling policy for whole-agent tool lifecycle events.
132///
133/// The production-safe default rejects every tool or side effect. Canonical
134/// MCP lifecycle handling must be selected before the runtime is used so a
135/// caller cannot widen a live turn after it begins.
136#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
137pub enum CodexToolPolicy {
138    #[default]
139    RejectAll,
140    CanonicalMcpLifecycle,
141}
142
143#[derive(Clone, Copy, Debug, PartialEq, Eq)]
144pub enum ToolCallStatus {
145    Completed,
146    Failed,
147}
148
149/// Content-free mapping of canonical `turn/completed` failure metadata.
150#[derive(Clone, Copy, Debug, PartialEq, Eq)]
151pub enum TerminalFailureClass {
152    ContextWindow,
153    UsageLimit,
154    Transport,
155    InvalidRequest,
156    Unauthorized,
157    Sandbox,
158    Internal,
159    Other,
160    Unclassified,
161}
162
163#[derive(Clone, Copy, Debug, PartialEq, Eq)]
164pub enum TerminalStatus {
165    Completed,
166    Failed,
167    Interrupted,
168}
169
170#[derive(Clone, PartialEq, Eq)]
171pub struct TurnCompletionReceipt {
172    pub thread_id: String,
173    pub turn_id: String,
174    pub selected_model: String,
175    pub selected_effort: ReasoningEffort,
176    pub status: TerminalStatus,
177    pub output: Option<String>,
178    pub replay: ReplayDisposition,
179    pub failure: Option<TerminalFailureClass>,
180    /// Authoritative input tokens for this turn only. Successful turns always
181    /// contain this value; failed or interrupted turns may not have emitted it.
182    pub input_tokens: Option<u64>,
183    /// Authoritative output tokens for this turn only. Successful turns always
184    /// contain this value; failed or interrupted turns may not have emitted it.
185    pub output_tokens: Option<u64>,
186}
187
188impl fmt::Debug for TurnCompletionReceipt {
189    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
190        formatter
191            .debug_struct("TurnCompletionReceipt")
192            .field("thread_id", &self.thread_id)
193            .field("turn_id", &self.turn_id)
194            .field("selected_model", &self.selected_model)
195            .field("selected_effort", &self.selected_effort)
196            .field("status", &self.status)
197            .field("output", &self.output.as_ref().map(|_| "[REDACTED]"))
198            .field("replay", &self.replay)
199            .field("failure", &self.failure)
200            .field("input_tokens", &self.input_tokens)
201            .field("output_tokens", &self.output_tokens)
202            .finish()
203    }
204}
205
206#[derive(Clone, Debug, PartialEq, Eq)]
207pub enum RuntimeEvent {
208    InitializeAccepted,
209    /// A strictly validated, content-free compatibility marker. The process
210    /// client consumes this marker immediately and never buffers it.
211    NonSemanticNotificationIgnored,
212    ManagedAccount(ManagedAccountProjection),
213    ModelPage(ModelPageProjection),
214    RateLimits(RateLimitsProjection),
215    ThreadStarted {
216        thread_id: String,
217    },
218    TurnAccepted {
219        thread_id: String,
220        turn_id: String,
221    },
222    TurnStarted {
223        thread_id: String,
224        turn_id: String,
225    },
226    ItemStarted {
227        thread_id: String,
228        turn_id: String,
229        item_id: String,
230    },
231    ItemDelta {
232        thread_id: String,
233        turn_id: String,
234        item_id: String,
235    },
236    ItemCompleted {
237        thread_id: String,
238        turn_id: String,
239        item_id: String,
240    },
241    ToolCallStarted {
242        thread_id: String,
243        turn_id: String,
244        item_id: String,
245    },
246    ToolCallCompleted {
247        thread_id: String,
248        turn_id: String,
249        item_id: String,
250        status: ToolCallStatus,
251    },
252    ModelReroutedFailure {
253        thread_id: String,
254        turn_id: String,
255        from_model: String,
256        to_model: String,
257    },
258    InterruptAcknowledged {
259        thread_id: String,
260        turn_id: String,
261    },
262    TurnCompleted(TurnCompletionReceipt),
263}
264
265#[derive(Clone, Copy, Debug, PartialEq, Eq)]
266enum HandshakeState {
267    New,
268    InitializePending,
269    InitializeAccepted,
270    Ready,
271}
272
273#[derive(Clone, Debug)]
274enum PendingRequest {
275    Initialize,
276    AccountRead,
277    ModelList,
278    RateLimitsRead,
279    ThreadStart {
280        model: String,
281        effort: ReasoningEffort,
282    },
283    TurnStart {
284        thread_id: String,
285        model: String,
286        effort: ReasoningEffort,
287    },
288    Interrupt {
289        thread_id: String,
290        turn_id: String,
291    },
292}
293
294#[derive(Clone, Debug)]
295struct ThreadSelection {
296    model: String,
297    effort: ReasoningEffort,
298}
299
300/// Notification-first correlation keyed by the still-pending `turn/start`
301/// JSON-RPC request ID. Values are content-free and consumed by the response.
302#[derive(Clone, Debug, PartialEq, Eq)]
303struct PendingTurnStarted {
304    thread_id: String,
305    turn_id: String,
306}
307
308#[derive(Clone, Debug)]
309enum ItemKind {
310    AgentMessage,
311    Reasoning,
312    McpToolCall(ToolLifecycleBinding),
313}
314
315#[derive(Clone, Debug)]
316struct ToolLifecycleBinding {
317    item_id: String,
318    server: String,
319    tool: String,
320    arguments_digest: [u8; 32],
321}
322
323#[derive(Clone, Debug)]
324struct ItemState {
325    kind: ItemKind,
326    delta_bytes: usize,
327    completed: Option<String>,
328    lifecycle_complete: bool,
329}
330
331#[derive(Clone, Debug)]
332struct TurnState {
333    model: String,
334    effort: ReasoningEffort,
335    items: BTreeMap<String, ItemState>,
336    final_item: Option<String>,
337    visible_output: bool,
338    tool_or_side_effect: bool,
339    model_rerouted: bool,
340    interrupt_requested: bool,
341    lifecycle_started: bool,
342    output_bytes: usize,
343    usage: Option<TurnUsageCounts>,
344    // The app-server's `total` projection is a private, monotonic admission
345    // baseline. Receipt accounting remains bound to the latest `last`
346    // projection: `TurnCompletionReceipt` is explicitly per-turn, while
347    // `total` is cumulative across the app-server's accounting scope.
348    total_usage: Option<CumulativeTurnUsageCounts>,
349}
350
351#[derive(Clone, Copy, Debug, PartialEq, Eq)]
352struct TurnUsageCounts {
353    input_tokens: u64,
354    output_tokens: u64,
355}
356
357#[derive(Clone, Copy, PartialEq, Eq)]
358struct CumulativeTurnUsageCounts {
359    cached_input_tokens: u64,
360    cache_write_input_tokens: Option<u64>,
361    input_tokens: u64,
362    output_tokens: u64,
363    reasoning_output_tokens: u64,
364    total_tokens: u64,
365}
366
367impl std::fmt::Debug for CumulativeTurnUsageCounts {
368    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
369        formatter.write_str("CumulativeTurnUsageCounts(<redacted>)")
370    }
371}
372
373impl TurnState {
374    fn new(model: String, effort: ReasoningEffort, lifecycle_started: bool) -> Self {
375        Self {
376            model,
377            effort,
378            items: BTreeMap::new(),
379            final_item: None,
380            visible_output: false,
381            tool_or_side_effect: false,
382            model_rerouted: false,
383            interrupt_requested: false,
384            lifecycle_started,
385            output_bytes: 0,
386            usage: None,
387            total_usage: None,
388        }
389    }
390
391    fn replay(&self) -> ReplayDisposition {
392        if self.model_rerouted {
393            ReplayDisposition::DeniedModelReroute
394        } else if self.tool_or_side_effect {
395            ReplayDisposition::DeniedToolOrSideEffect
396        } else if self.visible_output {
397            ReplayDisposition::DeniedVisibleOutput
398        } else {
399            ReplayDisposition::EligibleBeforeVisibility
400        }
401    }
402}
403
404#[derive(Debug, Error, PartialEq, Eq)]
405pub enum RuntimeError {
406    #[error("app-server runtime input is invalid")]
407    InvalidInput,
408    #[error("app-server runtime input contained forbidden sensitive material")]
409    SensitiveInputRejected,
410    #[error("app-server handshake state is invalid")]
411    InvalidHandshake,
412    #[error("app-server runtime is not initialized")]
413    NotInitialized,
414    #[error("app-server pending request capacity is exhausted")]
415    PendingCapacityExceeded,
416    #[error("app-server response correlation is unknown")]
417    UnknownCorrelation,
418    #[error("app-server response was a protocol failure")]
419    ProtocolFailure,
420    #[error("app-server returned a JSON-RPC error response")]
421    JsonRpcRejected,
422    #[error("unsupported app-server notification method: {0}")]
423    UnsupportedNotificationMethod(String),
424    #[error("the account is not managed ChatGPT")]
425    AccountNotManagedChatgpt,
426    #[error("the app-server model page exceeds configured bounds")]
427    ModelPageLimitExceeded,
428    #[error("the requested model is not present in the bounded catalog")]
429    UnknownModel,
430    #[error("the requested reasoning effort is not advertised by the model")]
431    UnsupportedEffort,
432    #[error("the app-server event does not match an active thread or turn")]
433    CorrelationMismatch,
434    #[error("a tool or side-effecting item was rejected")]
435    SideEffectingItemRejected,
436    #[error("the app-server output exceeds configured bounds")]
437    OutputLimitExceeded,
438}
439
440pub struct CodexAgentRuntime {
441    tool_policy: CodexToolPolicy,
442    handshake: HandshakeState,
443    next_id: u64,
444    pending: BTreeMap<u64, PendingRequest>,
445    /// At most `MAX_TURNS` notification-first markers may coexist with active
446    /// turns. A marker never authorizes replay and cannot outlive this runtime.
447    pending_turn_started: BTreeMap<u64, PendingTurnStarted>,
448    models: BTreeMap<String, ModelProjection>,
449    threads: BTreeMap<String, ThreadSelection>,
450    turns: BTreeMap<(String, String), TurnState>,
451    initialized_server_version: Option<String>,
452}
453
454impl fmt::Debug for CodexAgentRuntime {
455    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
456        formatter
457            .debug_struct("CodexAgentRuntime")
458            .field("tool_policy", &self.tool_policy)
459            .field("handshake", &self.handshake)
460            .field("pending_count", &self.pending.len())
461            .field(
462                "pending_turn_started_count",
463                &self.pending_turn_started.len(),
464            )
465            .field("model_count", &self.models.len())
466            .field("thread_count", &self.threads.len())
467            .field("turn_count", &self.turns.len())
468            .field("transcript", &"[REDACTED]")
469            .finish()
470    }
471}
472
473impl Default for CodexAgentRuntime {
474    fn default() -> Self {
475        Self::new()
476    }
477}
478
479impl CodexAgentRuntime {
480    pub fn new() -> Self {
481        Self::with_tool_policy(CodexToolPolicy::RejectAll)
482    }
483
484    pub fn with_tool_policy(tool_policy: CodexToolPolicy) -> Self {
485        Self {
486            tool_policy,
487            handshake: HandshakeState::New,
488            next_id: 1,
489            pending: BTreeMap::new(),
490            pending_turn_started: BTreeMap::new(),
491            models: BTreeMap::new(),
492            threads: BTreeMap::new(),
493            turns: BTreeMap::new(),
494            initialized_server_version: None,
495        }
496    }
497
498    /// Version attested by the exact app-server child that answered initialize.
499    pub fn initialized_server_version(&self) -> Option<&str> {
500        self.initialized_server_version.as_deref()
501    }
502
503    pub fn initialize_request(&mut self) -> Result<String, RuntimeError> {
504        if self.handshake != HandshakeState::New {
505            return Err(RuntimeError::InvalidHandshake);
506        }
507        let line = self.request(
508            "initialize",
509            json!({
510                "clientInfo":{"name":"steadymux","title":"SteadyMux","version":env!("CARGO_PKG_VERSION")},
511                "capabilities":{"experimentalApi":false}
512            }),
513            PendingRequest::Initialize,
514        )?;
515        self.handshake = HandshakeState::InitializePending;
516        Ok(line)
517    }
518
519    pub fn initialized_notification(&mut self) -> Result<String, RuntimeError> {
520        if self.handshake != HandshakeState::InitializeAccepted {
521            return Err(RuntimeError::InvalidHandshake);
522        }
523        self.handshake = HandshakeState::Ready;
524        encode_line(&json!({"jsonrpc":"2.0","method":"initialized"}))
525    }
526
527    pub fn account_read_request(&mut self) -> Result<String, RuntimeError> {
528        self.ready_request(
529            "account/read",
530            json!({"refreshToken":false}),
531            PendingRequest::AccountRead,
532        )
533    }
534
535    pub fn model_list_request(&mut self, cursor: Option<&str>) -> Result<String, RuntimeError> {
536        let cursor = cursor.map(validated_id).transpose()?;
537        self.ready_request(
538            "model/list",
539            json!({"cursor":cursor,"limit":MODEL_PAGE_LIMIT,"includeHidden":false}),
540            PendingRequest::ModelList,
541        )
542    }
543
544    pub fn rate_limits_read_request(&mut self) -> Result<String, RuntimeError> {
545        self.ready_request(
546            "account/rateLimits/read",
547            json!({}),
548            PendingRequest::RateLimitsRead,
549        )
550    }
551
552    pub fn thread_start_request(
553        &mut self,
554        model: &str,
555        effort: ReasoningEffort,
556    ) -> Result<String, RuntimeError> {
557        self.thread_start_request_with_instructions(model, effort, None, None)
558    }
559
560    pub fn thread_start_request_with_instructions(
561        &mut self,
562        model: &str,
563        effort: ReasoningEffort,
564        base_instructions: Option<&str>,
565        developer_instructions: Option<&str>,
566    ) -> Result<String, RuntimeError> {
567        self.require_model_effort(model, effort)?;
568        let base_instructions = validated_instruction(base_instructions)?;
569        let developer_instructions = validated_instruction(developer_instructions)?;
570        let mut params = json!({
571            "model":model,
572            "modelProvider":"openai",
573            "approvalPolicy":"never",
574            "sandbox":"read-only",
575            "ephemeral":true
576        });
577        let params_object = params.as_object_mut().ok_or(RuntimeError::InvalidInput)?;
578        if let Some(base_instructions) = base_instructions {
579            params_object.insert(
580                "baseInstructions".to_owned(),
581                Value::String(base_instructions),
582            );
583        }
584        if let Some(developer_instructions) = developer_instructions {
585            params_object.insert(
586                "developerInstructions".to_owned(),
587                Value::String(developer_instructions),
588            );
589        }
590        self.ready_request(
591            "thread/start",
592            params,
593            PendingRequest::ThreadStart {
594                model: model.to_owned(),
595                effort,
596            },
597        )
598    }
599
600    pub fn turn_start_request(
601        &mut self,
602        thread_id: &str,
603        client_message_id: &str,
604        text: &str,
605        model: &str,
606        effort: ReasoningEffort,
607    ) -> Result<String, RuntimeError> {
608        let thread_id = validated_id(thread_id)?;
609        let client_message_id = validated_id(client_message_id)?;
610        if text.is_empty() || text.len() > MAX_TEXT_BYTES || text.contains('\0') {
611            return Err(RuntimeError::InvalidInput);
612        }
613        self.require_model_effort(model, effort)?;
614        let selection = self
615            .threads
616            .get(&thread_id)
617            .ok_or(RuntimeError::CorrelationMismatch)?;
618        if selection.model != model || selection.effort != effort {
619            return Err(RuntimeError::CorrelationMismatch);
620        }
621        self.ready_request(
622            "turn/start",
623            json!({
624                "threadId":thread_id,
625                "clientUserMessageId":client_message_id,
626                "input":[{"type":"text","text":text}],
627                "model":model,
628                "effort":effort,
629                "approvalPolicy":"never",
630                "sandboxPolicy":{"type":"readOnly"}
631            }),
632            PendingRequest::TurnStart {
633                thread_id,
634                model: model.to_owned(),
635                effort,
636            },
637        )
638    }
639
640    pub fn turn_interrupt_request(
641        &mut self,
642        thread_id: &str,
643        turn_id: &str,
644    ) -> Result<String, RuntimeError> {
645        let thread_id = validated_id(thread_id)?;
646        let turn_id = validated_id(turn_id)?;
647        let turn = self
648            .turns
649            .get(&(thread_id.clone(), turn_id.clone()))
650            .ok_or(RuntimeError::CorrelationMismatch)?;
651        if turn.interrupt_requested {
652            return Err(RuntimeError::CorrelationMismatch);
653        }
654        let line = self.ready_request(
655            "turn/interrupt",
656            json!({"threadId":thread_id,"turnId":turn_id}),
657            PendingRequest::Interrupt {
658                thread_id: thread_id.clone(),
659                turn_id: turn_id.clone(),
660            },
661        )?;
662        self.turns
663            .get_mut(&(thread_id, turn_id))
664            .ok_or(RuntimeError::CorrelationMismatch)?
665            .interrupt_requested = true;
666        Ok(line)
667    }
668
669    pub fn ingest_line(&mut self, line: &[u8]) -> Result<RuntimeEvent, RuntimeError> {
670        if line.is_empty()
671            || line.len() > MAX_LINE_BYTES
672            || line.contains(&b'\n')
673            || line.contains(&b'\r')
674        {
675            return Err(RuntimeError::InvalidInput);
676        }
677        let value: Value = serde_json::from_slice(line).map_err(|_| RuntimeError::InvalidInput)?;
678        if let Some(id) = value.get("id").and_then(Value::as_u64) {
679            if value.get("error").is_none() && contains_forbidden_sensitive_material(&value) {
680                return Err(RuntimeError::SensitiveInputRejected);
681            }
682            let result = self.ingest_response(id, &value);
683            if matches!(result, Err(RuntimeError::ProtocolFailure)) {
684                eprintln!("steadymux-owned-child: runtime-protocol-source=response");
685            } else if matches!(result, Err(RuntimeError::CorrelationMismatch)) {
686                eprintln!("{}", correlation_mismatch_response_marker());
687            }
688            return result;
689        }
690        if contains_forbidden_sensitive_material(&value) {
691            return Err(RuntimeError::SensitiveInputRejected);
692        }
693        let method = value
694            .get("method")
695            .and_then(Value::as_str)
696            .ok_or(RuntimeError::InvalidInput)?;
697        let params = value.get("params").ok_or(RuntimeError::InvalidInput)?;
698        let result = self.ingest_notification(method, params);
699        if matches!(result, Err(RuntimeError::ProtocolFailure)) {
700            let method_sha256 = Sha256::digest(method.as_bytes())
701                .iter()
702                .map(|byte| format!("{byte:02x}"))
703                .collect::<String>();
704            eprintln!(
705                "steadymux-owned-child: runtime-protocol-source=notification method-sha256={method_sha256}"
706            );
707        } else if matches!(result, Err(RuntimeError::CorrelationMismatch)) {
708            eprintln!("{}", correlation_mismatch_notification_marker(method));
709        }
710        result
711    }
712
713    fn request(
714        &mut self,
715        method: &str,
716        params: Value,
717        pending: PendingRequest,
718    ) -> Result<String, RuntimeError> {
719        if self.pending.len() >= MAX_PENDING {
720            return Err(RuntimeError::PendingCapacityExceeded);
721        }
722        let id = self.next_id;
723        self.next_id = self
724            .next_id
725            .checked_add(1)
726            .ok_or(RuntimeError::PendingCapacityExceeded)?;
727        self.pending.insert(id, pending);
728        encode_line(&json!({"jsonrpc":"2.0","id":id,"method":method,"params":params}))
729    }
730
731    fn ready_request(
732        &mut self,
733        method: &str,
734        params: Value,
735        pending: PendingRequest,
736    ) -> Result<String, RuntimeError> {
737        if self.handshake != HandshakeState::Ready {
738            return Err(RuntimeError::NotInitialized);
739        }
740        self.request(method, params, pending)
741    }
742
743    fn require_model_effort(
744        &self,
745        model: &str,
746        effort: ReasoningEffort,
747    ) -> Result<(), RuntimeError> {
748        let model = self.models.get(model).ok_or(RuntimeError::UnknownModel)?;
749        if !model.supported_efforts.contains(&effort) {
750            return Err(RuntimeError::UnsupportedEffort);
751        }
752        Ok(())
753    }
754
755    fn ingest_response(&mut self, id: u64, value: &Value) -> Result<RuntimeEvent, RuntimeError> {
756        let pending = self
757            .pending
758            .remove(&id)
759            .ok_or(RuntimeError::UnknownCorrelation)?;
760        let pending_turn_started = self.pending_turn_started.remove(&id);
761        if pending_turn_started.is_some() && !matches!(&pending, PendingRequest::TurnStart { .. }) {
762            self.discard_notification_first_turn(pending_turn_started.as_ref());
763            return Err(RuntimeError::CorrelationMismatch);
764        }
765        if value.get("error").is_some() {
766            self.discard_notification_first_turn(pending_turn_started.as_ref());
767            return if valid_json_rpc_error_response(value) {
768                Err(RuntimeError::JsonRpcRejected)
769            } else {
770                Err(RuntimeError::ProtocolFailure)
771            };
772        }
773        let result = match value.get("result") {
774            Some(result) => result,
775            None => {
776                self.discard_notification_first_turn(pending_turn_started.as_ref());
777                return Err(RuntimeError::InvalidInput);
778            }
779        };
780        match pending {
781            PendingRequest::Initialize => {
782                if self.handshake != HandshakeState::InitializePending || !result.is_object() {
783                    return Err(RuntimeError::InvalidHandshake);
784                }
785                let user_agent = string_at(result, &["userAgent"])?;
786                self.initialized_server_version =
787                    app_server_version_from_user_agent(user_agent).map(str::to_owned);
788                self.handshake = HandshakeState::InitializeAccepted;
789                Ok(RuntimeEvent::InitializeAccepted)
790            }
791            PendingRequest::AccountRead => self.ingest_account(result),
792            PendingRequest::ModelList => self.ingest_models(result),
793            PendingRequest::RateLimitsRead => self.ingest_rate_limits(result),
794            PendingRequest::ThreadStart { model, effort } => {
795                let thread_id = validated_id(string_at(result, &["thread", "id"])?)?;
796                if self.threads.len() >= MAX_THREADS || self.threads.contains_key(&thread_id) {
797                    return Err(RuntimeError::CorrelationMismatch);
798                }
799                self.threads
800                    .insert(thread_id.clone(), ThreadSelection { model, effort });
801                Ok(RuntimeEvent::ThreadStarted { thread_id })
802            }
803            PendingRequest::TurnStart {
804                thread_id,
805                model,
806                effort,
807            } => {
808                let parsed_response = (|| {
809                    let turn_id = validated_id(string_at(result, &["turn", "id"])?)?;
810                    if string_at(result, &["turn", "status"])? != "inProgress" {
811                        return Err(RuntimeError::InvalidInput);
812                    }
813                    Ok(turn_id)
814                })();
815                let turn_id = match parsed_response {
816                    Ok(turn_id) => turn_id,
817                    Err(error) => {
818                        self.discard_notification_first_turn(pending_turn_started.as_ref());
819                        return Err(error);
820                    }
821                };
822                let key = (thread_id.clone(), turn_id.clone());
823                match pending_turn_started {
824                    Some(started) => {
825                        let exact_match = started.thread_id == thread_id
826                            && started.turn_id == turn_id
827                            && self.turns.get(&key).is_some_and(|turn| {
828                                turn.model == model
829                                    && turn.effort == effort
830                                    && turn.lifecycle_started
831                            });
832                        if !exact_match {
833                            self.discard_notification_first_turn(Some(&started));
834                            return Err(RuntimeError::CorrelationMismatch);
835                        }
836                    }
837                    None => {
838                        if self.turns.len() >= MAX_TURNS || self.turns.contains_key(&key) {
839                            return Err(RuntimeError::CorrelationMismatch);
840                        }
841                        self.turns.insert(key, TurnState::new(model, effort, false));
842                    }
843                }
844                Ok(RuntimeEvent::TurnAccepted { thread_id, turn_id })
845            }
846            PendingRequest::Interrupt { thread_id, turn_id } => {
847                if result.as_object().is_none_or(|object| !object.is_empty()) {
848                    return Err(RuntimeError::InvalidInput);
849                }
850                Ok(RuntimeEvent::InterruptAcknowledged { thread_id, turn_id })
851            }
852        }
853    }
854
855    fn ingest_account(&self, result: &Value) -> Result<RuntimeEvent, RuntimeError> {
856        let account = result
857            .get("account")
858            .ok_or(RuntimeError::AccountNotManagedChatgpt)?;
859        if account.get("type").and_then(Value::as_str) != Some("chatgpt") {
860            return Err(RuntimeError::AccountNotManagedChatgpt);
861        }
862        let plan_type = account
863            .get("planType")
864            .and_then(Value::as_str)
865            .map(bounded_text)
866            .transpose()?;
867        let requires_openai_auth = result
868            .get("requiresOpenaiAuth")
869            .and_then(Value::as_bool)
870            .ok_or(RuntimeError::InvalidInput)?;
871        Ok(RuntimeEvent::ManagedAccount(ManagedAccountProjection {
872            plan_type,
873            requires_openai_auth,
874        }))
875    }
876
877    fn ingest_models(&mut self, result: &Value) -> Result<RuntimeEvent, RuntimeError> {
878        let data = result
879            .get("data")
880            .and_then(Value::as_array)
881            .ok_or(RuntimeError::InvalidInput)?;
882        if data.len() > MODEL_PAGE_LIMIT
883            || self.models.len().saturating_add(data.len()) > MAX_MODELS
884        {
885            return Err(RuntimeError::ModelPageLimitExceeded);
886        }
887        let mut page = Vec::with_capacity(data.len());
888        let mut page_ids = BTreeSet::new();
889        for raw in data {
890            if raw.get("hidden").and_then(Value::as_bool).unwrap_or(false) {
891                continue;
892            }
893            let id = validated_id(
894                raw.get("id")
895                    .and_then(Value::as_str)
896                    .ok_or(RuntimeError::InvalidInput)?,
897            )?;
898            if self.models.contains_key(&id) || !page_ids.insert(id.clone()) {
899                return Err(RuntimeError::InvalidInput);
900            }
901            let display_name = bounded_text(
902                raw.get("displayName")
903                    .and_then(Value::as_str)
904                    .ok_or(RuntimeError::InvalidInput)?,
905            )?;
906            let effort_values = raw
907                .get("supportedReasoningEfforts")
908                .and_then(Value::as_array)
909                .ok_or(RuntimeError::InvalidInput)?;
910            if effort_values.is_empty() || effort_values.len() > 8 {
911                return Err(RuntimeError::InvalidInput);
912            }
913            let mut seen = BTreeSet::new();
914            let mut efforts = Vec::with_capacity(effort_values.len());
915            for effort in effort_values {
916                let effort_value = effort
917                    .get("reasoningEffort")
918                    .and_then(Value::as_str)
919                    .ok_or(RuntimeError::InvalidInput)?;
920                let effort = parse_effort(effort_value)?;
921                if seen.insert(effort) {
922                    efforts.push(effort);
923                }
924            }
925            let projection = ModelProjection {
926                id: id.clone(),
927                display_name,
928                supported_efforts: efforts,
929            };
930            page.push(projection);
931        }
932        let next_cursor = result
933            .get("nextCursor")
934            .and_then(Value::as_str)
935            .map(validated_id)
936            .transpose()?;
937        for projection in &page {
938            self.models
939                .insert(projection.id.clone(), projection.clone());
940        }
941        Ok(RuntimeEvent::ModelPage(ModelPageProjection {
942            models: page,
943            next_cursor,
944        }))
945    }
946
947    fn ingest_rate_limits(&self, result: &Value) -> Result<RuntimeEvent, RuntimeError> {
948        let limits = result.get("rateLimits").ok_or(RuntimeError::InvalidInput)?;
949        let primary = parse_window(limits.get("primary"))?;
950        let secondary = parse_window(limits.get("secondary"))?;
951        let reached_type = limits
952            .get("rateLimitReachedType")
953            .and_then(Value::as_str)
954            .map(bounded_text)
955            .transpose()?;
956        Ok(RuntimeEvent::RateLimits(RateLimitsProjection {
957            primary,
958            secondary,
959            reached_type,
960        }))
961    }
962
963    fn ingest_notification(
964        &mut self,
965        method: &str,
966        params: &Value,
967    ) -> Result<RuntimeEvent, RuntimeError> {
968        match method {
969            "remoteControl/status/changed" => {
970                let object = params.as_object().ok_or(RuntimeError::ProtocolFailure)?;
971                let exact_shape = object.len() == 4
972                    && object.get("status").and_then(Value::as_str) == Some("disabled")
973                    && valid_opaque_control_value(object.get("serverName"))
974                    && valid_opaque_control_value(object.get("installationId"))
975                    && object.get("environmentId").is_some_and(Value::is_null);
976                if !exact_shape {
977                    return Err(RuntimeError::ProtocolFailure);
978                }
979                Ok(RuntimeEvent::NonSemanticNotificationIgnored)
980            }
981            "mcpServer/startupStatus/updated" => self.ingest_mcp_startup_status(params),
982            "warning" => self.ingest_warning(params),
983            "hook/started" | "hook/completed" => Err(RuntimeError::ProtocolFailure),
984            "thread/started" => self.ingest_thread_started(params),
985            "thread/status/changed" => self.ingest_thread_status_changed(params),
986            "thread/tokenUsage/updated" => self.ingest_thread_token_usage_updated(params),
987            "account/rateLimits/updated" => self.ingest_rate_limits_updated(params),
988            "item/reasoning/summaryPartAdded" => self.ingest_reasoning_part_added(params),
989            "item/reasoning/summaryTextDelta" => {
990                self.ingest_reasoning_delta(params, "summaryIndex")
991            }
992            "item/reasoning/textDelta" => self.ingest_reasoning_delta(params, "contentIndex"),
993            "turn/started" => self.ingest_turn_started(params),
994            "item/started" => self.ingest_item_started(params),
995            "item/agentMessage/delta" => self.ingest_delta(params),
996            "item/completed" => self.ingest_item_completed(params),
997            "model/rerouted" => self.ingest_rerouted(params),
998            "turn/completed" => self.ingest_turn_completed(params),
999            _ => match validated_notification_method(method) {
1000                Ok(method) => Err(RuntimeError::UnsupportedNotificationMethod(method)),
1001                Err(_) => Err(RuntimeError::ProtocolFailure),
1002            },
1003        }
1004    }
1005
1006    fn ingest_mcp_startup_status(&self, params: &Value) -> Result<RuntimeEvent, RuntimeError> {
1007        const ALLOWED: [&str; 5] = ["threadId", "name", "status", "error", "failureReason"];
1008        let object = params.as_object().ok_or(RuntimeError::ProtocolFailure)?;
1009        let refusal = if !object.contains_key("name")
1010            || !object.contains_key("status")
1011            || !object.keys().all(|key| ALLOWED.contains(&key.as_str()))
1012        {
1013            Some("keys")
1014        } else if !valid_opaque_control_value(object.get("name")) {
1015            Some("name")
1016        } else if !matches!(
1017            object.get("status").and_then(Value::as_str),
1018            Some("starting" | "ready" | "failed" | "cancelled")
1019        ) {
1020            Some("status")
1021        } else if object.get("error").is_some_and(|error| {
1022            !error.is_null()
1023                && error.as_str().is_none_or(|error| {
1024                    error.is_empty()
1025                        || error.len() > MAX_TEXT_BYTES
1026                        || error.chars().any(char::is_control)
1027                })
1028        }) {
1029            Some("error")
1030        } else if object.get("failureReason").is_some_and(|reason| {
1031            !reason.is_null() && reason.as_str() != Some("reauthenticationRequired")
1032        }) {
1033            Some("reason")
1034        } else if object
1035            .get("threadId")
1036            .is_some_and(|thread_id| !thread_id.is_null() && !thread_id.is_string())
1037        {
1038            Some("thread")
1039        } else {
1040            None
1041        };
1042        if let Some(class) = refusal {
1043            eprintln!("steadymux-owned-child: mcp-startup-refused={class}");
1044            return Err(RuntimeError::ProtocolFailure);
1045        }
1046        if let Some(thread_id) = object.get("threadId").and_then(Value::as_str) {
1047            let thread_id = validated_id(thread_id)?;
1048            if !self.threads.contains_key(&thread_id) {
1049                return Err(RuntimeError::CorrelationMismatch);
1050            }
1051        }
1052        Ok(RuntimeEvent::NonSemanticNotificationIgnored)
1053    }
1054
1055    fn ingest_warning(&self, params: &Value) -> Result<RuntimeEvent, RuntimeError> {
1056        let object = exact_object(params, &["threadId", "message"])?;
1057        let thread_id = object
1058            .get("threadId")
1059            .and_then(Value::as_str)
1060            .filter(|value| {
1061                !value.is_empty() && value.len() <= MAX_ID_BYTES && !value.contains('\0')
1062            })
1063            .ok_or(RuntimeError::ProtocolFailure)?;
1064        if !self.threads.contains_key(thread_id) {
1065            return Err(RuntimeError::CorrelationMismatch);
1066        }
1067        if object
1068            .get("message")
1069            .and_then(Value::as_str)
1070            .is_none_or(|message| {
1071                message.is_empty() || message.len() > MAX_TEXT_BYTES || message.contains('\0')
1072            })
1073        {
1074            return Err(RuntimeError::ProtocolFailure);
1075        }
1076        Ok(RuntimeEvent::NonSemanticNotificationIgnored)
1077    }
1078
1079    fn ingest_rate_limits_updated(&self, params: &Value) -> Result<RuntimeEvent, RuntimeError> {
1080        let object = exact_object(params, &["rateLimits"])?;
1081        let limits = exact_object(
1082            object
1083                .get("rateLimits")
1084                .ok_or(RuntimeError::ProtocolFailure)?,
1085            &[
1086                "limitId",
1087                "limitName",
1088                "primary",
1089                "secondary",
1090                "credits",
1091                "individualLimit",
1092                "spendControlReached",
1093                "planType",
1094                "rateLimitReachedType",
1095            ],
1096        )?;
1097        let primary = exact_object(
1098            limits.get("primary").ok_or(RuntimeError::ProtocolFailure)?,
1099            &["usedPercent", "windowDurationMins", "resetsAt"],
1100        )?;
1101        let credits = exact_object(
1102            limits.get("credits").ok_or(RuntimeError::ProtocolFailure)?,
1103            &["hasCredits", "unlimited", "balance"],
1104        )?;
1105        let valid = valid_opaque_control_value(limits.get("limitId"))
1106            && limits.get("limitName").is_some_and(Value::is_null)
1107            && limits.get("secondary").is_some_and(Value::is_null)
1108            && limits.get("individualLimit").is_some_and(Value::is_null)
1109            && limits
1110                .get("spendControlReached")
1111                .is_some_and(|value| value.is_null() || value.is_boolean())
1112            && limits.get("rateLimitReachedType").is_some_and(|value| {
1113                value.is_null()
1114                    || value
1115                        .as_str()
1116                        .is_some_and(|text| bounded_text(text).is_ok())
1117            })
1118            && valid_opaque_control_value(limits.get("planType"))
1119            && primary
1120                .get("usedPercent")
1121                .and_then(Value::as_u64)
1122                .is_some_and(|percent| percent <= 100)
1123            && primary
1124                .get("windowDurationMins")
1125                .and_then(Value::as_u64)
1126                .is_some()
1127            && primary.get("resetsAt").and_then(Value::as_u64).is_some()
1128            && credits.get("hasCredits").and_then(Value::as_bool).is_some()
1129            && credits.get("unlimited").and_then(Value::as_bool).is_some()
1130            && credits
1131                .get("balance")
1132                .and_then(Value::as_str)
1133                .is_some_and(|balance| {
1134                    !balance.is_empty()
1135                        && balance.len() <= MAX_ID_BYTES
1136                        && balance
1137                            .bytes()
1138                            .all(|byte| byte.is_ascii_digit() || byte == b'.')
1139                });
1140        if !valid {
1141            return Err(RuntimeError::ProtocolFailure);
1142        }
1143        Ok(RuntimeEvent::NonSemanticNotificationIgnored)
1144    }
1145
1146    fn ingest_thread_started(&self, params: &Value) -> Result<RuntimeEvent, RuntimeError> {
1147        let object = exact_object(params, &["thread"])?;
1148        let thread_id = validate_ephemeral_empty_thread(
1149            object.get("thread").ok_or(RuntimeError::ProtocolFailure)?,
1150        )?;
1151        if !self.threads.contains_key(&thread_id) {
1152            return Err(RuntimeError::CorrelationMismatch);
1153        }
1154        Ok(RuntimeEvent::NonSemanticNotificationIgnored)
1155    }
1156
1157    fn ingest_turn_started(&mut self, params: &Value) -> Result<RuntimeEvent, RuntimeError> {
1158        let thread_id = validated_id(string_at(params, &["threadId"])?)?;
1159        let turn_id = validated_id(string_at(params, &["turn", "id"])?)?;
1160        if string_at(params, &["turn", "status"])? != "inProgress" {
1161            return Err(RuntimeError::ProtocolFailure);
1162        }
1163        if let Ok(turn) = self.turn_mut(&thread_id, &turn_id) {
1164            if turn.lifecycle_started {
1165                return Err(RuntimeError::CorrelationMismatch);
1166            }
1167            turn.lifecycle_started = true;
1168            return Ok(RuntimeEvent::TurnStarted { thread_id, turn_id });
1169        }
1170
1171        let mut matching_requests =
1172            self.pending
1173                .iter()
1174                .filter_map(|(request_id, request)| match request {
1175                    PendingRequest::TurnStart {
1176                        thread_id: pending_thread_id,
1177                        model,
1178                        effort,
1179                    } if pending_thread_id == &thread_id => {
1180                        Some((*request_id, model.clone(), *effort))
1181                    }
1182                    _ => None,
1183                });
1184        let (request_id, model, effort) = matching_requests
1185            .next()
1186            .ok_or(RuntimeError::CorrelationMismatch)?;
1187        if matching_requests.next().is_some() || self.pending_turn_started.contains_key(&request_id)
1188        {
1189            return Err(RuntimeError::CorrelationMismatch);
1190        }
1191        if self.turns.len() >= MAX_TURNS {
1192            return Err(RuntimeError::PendingCapacityExceeded);
1193        }
1194        self.pending_turn_started.insert(
1195            request_id,
1196            PendingTurnStarted {
1197                thread_id: thread_id.clone(),
1198                turn_id: turn_id.clone(),
1199            },
1200        );
1201        self.turns.insert(
1202            (thread_id.clone(), turn_id.clone()),
1203            TurnState::new(model, effort, true),
1204        );
1205        Ok(RuntimeEvent::TurnStarted { thread_id, turn_id })
1206    }
1207
1208    fn ingest_thread_status_changed(&self, params: &Value) -> Result<RuntimeEvent, RuntimeError> {
1209        let object = exact_object(params, &["threadId", "status"])?;
1210        let thread_id = validated_id(
1211            object
1212                .get("threadId")
1213                .and_then(Value::as_str)
1214                .ok_or(RuntimeError::ProtocolFailure)?,
1215        )?;
1216        if !self.threads.contains_key(&thread_id) {
1217            return Err(RuntimeError::CorrelationMismatch);
1218        }
1219        let status = object.get("status").ok_or(RuntimeError::ProtocolFailure)?;
1220        let status = status.as_object().ok_or(RuntimeError::ProtocolFailure)?;
1221        let allowed = match status.get("type").and_then(Value::as_str) {
1222            Some("idle") => status.len() == 1,
1223            Some("active") => {
1224                status.len() == 2
1225                    && status
1226                        .get("activeFlags")
1227                        .and_then(Value::as_array)
1228                        .is_some_and(|flags| {
1229                            flags.len() <= 2
1230                                && flags.iter().all(|flag| {
1231                                    matches!(
1232                                        flag.as_str(),
1233                                        Some("waitingOnApproval" | "waitingOnUserInput")
1234                                    )
1235                                })
1236                        })
1237            }
1238            _ => false,
1239        };
1240        if !allowed {
1241            return Err(RuntimeError::ProtocolFailure);
1242        }
1243        Ok(RuntimeEvent::NonSemanticNotificationIgnored)
1244    }
1245
1246    fn ingest_thread_token_usage_updated(
1247        &mut self,
1248        params: &Value,
1249    ) -> Result<RuntimeEvent, RuntimeError> {
1250        let object = exact_object(params, &["threadId", "turnId", "tokenUsage"])
1251            .map_err(|_| token_usage_refusal("params-shape"))?;
1252        let thread_id = object
1253            .get("threadId")
1254            .and_then(Value::as_str)
1255            .ok_or_else(|| token_usage_refusal("ids"))
1256            .and_then(|id| validated_id(id).map_err(|_| token_usage_refusal("ids")))?;
1257        let turn_id = object
1258            .get("turnId")
1259            .and_then(Value::as_str)
1260            .ok_or_else(|| token_usage_refusal("ids"))
1261            .and_then(|id| validated_id(id).map_err(|_| token_usage_refusal("ids")))?;
1262        let usage = validated_last_turn_usage(object.get("tokenUsage"))?;
1263        let total_usage = validated_total_turn_usage(object.get("tokenUsage"))?;
1264        let key = (thread_id.clone(), turn_id.clone());
1265        let correlation_refusal = match self.turns.get(&key) {
1266            None => Some(TokenUsageCorrelationRefusal::MissingPair),
1267            Some(turn) if turn.model_rerouted => Some(TokenUsageCorrelationRefusal::Rerouted),
1268            Some(turn) if turn.usage.is_some() && turn.total_usage.is_none() => {
1269                Some(TokenUsageCorrelationRefusal::PriorUsage)
1270            }
1271            Some(_) => None,
1272        };
1273        if let Some(reason) = correlation_refusal {
1274            emit_token_usage_correlation_marker(reason, &thread_id, &turn_id, &self.turns);
1275            return Err(token_usage_refusal("turn-state/correlation"));
1276        }
1277        if let Some(turn) = self.turns.get(&key)
1278            && let Some(previous_total) = turn.total_usage
1279        {
1280            let total_direction = cumulative_usage_direction(previous_total, total_usage);
1281            if total_direction == PriorUsageDirection::DecreaseOrMixed {
1282                emit_prior_usage_direction_marker(
1283                    turn.usage
1284                        .map_or(PriorUsageDirection::Unavailable, |previous| {
1285                            prior_usage_direction(previous, usage)
1286                        }),
1287                );
1288                emit_prior_usage_projection_marker(
1289                    turn.usage.map_or(TotalUsageRelativeToLast::Equal, |last| {
1290                        total_usage_relative_to_last(last, total_usage)
1291                    }),
1292                    total_direction,
1293                );
1294                emit_token_usage_correlation_marker(
1295                    TokenUsageCorrelationRefusal::PriorUsage,
1296                    &thread_id,
1297                    &turn_id,
1298                    &self.turns,
1299                );
1300                return Err(token_usage_refusal("turn-state/correlation"));
1301            }
1302        }
1303        let turn = self
1304            .turn_mut(&thread_id, &turn_id)
1305            .map_err(|_| token_usage_refusal("turn-state/correlation"))?;
1306        // Some app-server versions deliver this authoritative usage update
1307        // after the accepted turn-start response but before `turn/started`.
1308        // The response has already created the exact turn record, so this is
1309        // not notification-first admission.  Keep every other boundary
1310        // closed: notifications never create turns, reroutes cannot report
1311        // usage, and every later update must prove a nondecreasing complete
1312        // cumulative projection. `last` is replaceable protocol state, but
1313        // only after that cumulative proof succeeds.
1314        turn.usage = Some(usage);
1315        turn.total_usage = Some(total_usage);
1316        Ok(RuntimeEvent::NonSemanticNotificationIgnored)
1317    }
1318
1319    fn ingest_reasoning_part_added(&self, params: &Value) -> Result<RuntimeEvent, RuntimeError> {
1320        let object = exact_object(params, &["threadId", "turnId", "itemId", "summaryIndex"])?;
1321        let (thread_id, turn_id, item_id) = correlated_reasoning_ids(object, "summaryIndex")?;
1322        self.require_active_reasoning_item(&thread_id, &turn_id, &item_id)?;
1323        Ok(RuntimeEvent::NonSemanticNotificationIgnored)
1324    }
1325
1326    fn ingest_reasoning_delta(
1327        &mut self,
1328        params: &Value,
1329        index_key: &str,
1330    ) -> Result<RuntimeEvent, RuntimeError> {
1331        let object = exact_object(
1332            params,
1333            &["threadId", "turnId", "itemId", index_key, "delta"],
1334        )?;
1335        let (thread_id, turn_id, item_id) = correlated_reasoning_ids(object, index_key)?;
1336        let delta = object
1337            .get("delta")
1338            .and_then(Value::as_str)
1339            .filter(|delta| delta.len() <= MAX_TEXT_BYTES)
1340            .ok_or(RuntimeError::ProtocolFailure)?;
1341        let turn = self.turn_mut(&thread_id, &turn_id)?;
1342        if turn.model_rerouted || !turn.lifecycle_started {
1343            return Err(RuntimeError::ProtocolFailure);
1344        }
1345        let item = turn
1346            .items
1347            .get_mut(&item_id)
1348            .filter(|item| matches!(item.kind, ItemKind::Reasoning) && !item.lifecycle_complete)
1349            .ok_or(RuntimeError::CorrelationMismatch)?;
1350        if item.delta_bytes.saturating_add(delta.len()) > MAX_OUTPUT_BYTES
1351            || turn.output_bytes.saturating_add(delta.len()) > MAX_OUTPUT_BYTES
1352        {
1353            return Err(RuntimeError::OutputLimitExceeded);
1354        }
1355        item.delta_bytes += delta.len();
1356        turn.output_bytes += delta.len();
1357        Ok(RuntimeEvent::NonSemanticNotificationIgnored)
1358    }
1359
1360    fn require_active_reasoning_item(
1361        &self,
1362        thread_id: &str,
1363        turn_id: &str,
1364        item_id: &str,
1365    ) -> Result<(), RuntimeError> {
1366        let turn = self
1367            .turns
1368            .get(&(thread_id.to_owned(), turn_id.to_owned()))
1369            .ok_or(RuntimeError::CorrelationMismatch)?;
1370        if turn.model_rerouted || !turn.lifecycle_started {
1371            return Err(RuntimeError::ProtocolFailure);
1372        }
1373        turn.items
1374            .get(item_id)
1375            .filter(|item| matches!(item.kind, ItemKind::Reasoning) && !item.lifecycle_complete)
1376            .map(|_| ())
1377            .ok_or(RuntimeError::CorrelationMismatch)
1378    }
1379
1380    fn ingest_item_started(&mut self, params: &Value) -> Result<RuntimeEvent, RuntimeError> {
1381        let thread_id = validated_id(string_at(params, &["threadId"])?)?;
1382        let turn_id = validated_id(string_at(params, &["turnId"])?)?;
1383        let item = params.get("item").ok_or(RuntimeError::InvalidInput)?;
1384        let item_type = string_at(item, &["type"])?;
1385        if item_type == "userMessage" {
1386            self.validate_user_message_notification(params, "startedAtMs")?;
1387            return Ok(RuntimeEvent::NonSemanticNotificationIgnored);
1388        }
1389        let item_id = validated_id(string_at(item, &["id"])?)?;
1390        let item_kind = match item_type {
1391            "agentMessage" => ItemKind::AgentMessage,
1392            "reasoning" => ItemKind::Reasoning,
1393            "mcpToolCall" if self.tool_policy == CodexToolPolicy::CanonicalMcpLifecycle => {
1394                if string_at(item, &["status"])? != "inProgress"
1395                    || item.get("arguments").is_none()
1396                    || !null_or_absent(item, "result")
1397                    || !null_or_absent(item, "error")
1398                {
1399                    return Err(RuntimeError::ProtocolFailure);
1400                }
1401                ItemKind::McpToolCall(ToolLifecycleBinding {
1402                    item_id: item_id.clone(),
1403                    server: validated_tool_name(string_at(item, &["server"])?)?,
1404                    tool: validated_tool_name(string_at(item, &["tool"])?)?,
1405                    arguments_digest: digest_json(
1406                        item.get("arguments").ok_or(RuntimeError::ProtocolFailure)?,
1407                    )?,
1408                })
1409            }
1410            "mcpToolCall" => {
1411                emit_mcp_tool_policy_refusal_marker(item, McpToolLifecycle::Started);
1412                if let Ok(turn) = self.turn_mut(&thread_id, &turn_id) {
1413                    turn.tool_or_side_effect = true;
1414                }
1415                return Err(RuntimeError::SideEffectingItemRejected);
1416            }
1417            _ => {
1418                emit_side_effecting_item_type_marker(item_type);
1419                if let Ok(turn) = self.turn_mut(&thread_id, &turn_id) {
1420                    turn.tool_or_side_effect = true;
1421                }
1422                return Err(RuntimeError::SideEffectingItemRejected);
1423            }
1424        };
1425        let tool_call = matches!(item_kind, ItemKind::McpToolCall(_));
1426        let turn = self.turn_mut(&thread_id, &turn_id)?;
1427        if turn.model_rerouted || !turn.lifecycle_started {
1428            return Err(RuntimeError::ProtocolFailure);
1429        }
1430        if turn.items.len() >= MAX_ITEMS_PER_TURN || turn.items.contains_key(&item_id) {
1431            return Err(RuntimeError::CorrelationMismatch);
1432        }
1433        if tool_call {
1434            turn.tool_or_side_effect = true;
1435        }
1436        turn.items.insert(
1437            item_id.clone(),
1438            ItemState {
1439                kind: item_kind,
1440                delta_bytes: 0,
1441                completed: None,
1442                lifecycle_complete: false,
1443            },
1444        );
1445        if tool_call {
1446            Ok(RuntimeEvent::ToolCallStarted {
1447                thread_id,
1448                turn_id,
1449                item_id,
1450            })
1451        } else {
1452            Ok(RuntimeEvent::ItemStarted {
1453                thread_id,
1454                turn_id,
1455                item_id,
1456            })
1457        }
1458    }
1459
1460    fn ingest_delta(&mut self, params: &Value) -> Result<RuntimeEvent, RuntimeError> {
1461        let thread_id = validated_id(string_at(params, &["threadId"])?)?;
1462        let turn_id = validated_id(string_at(params, &["turnId"])?)?;
1463        let item_id = validated_id(string_at(params, &["itemId"])?)?;
1464        let delta = string_at(params, &["delta"])?;
1465        let turn = self.turn_mut(&thread_id, &turn_id)?;
1466        if turn.model_rerouted || !turn.lifecycle_started {
1467            return Err(RuntimeError::ProtocolFailure);
1468        }
1469        let item = turn
1470            .items
1471            .get_mut(&item_id)
1472            .ok_or(RuntimeError::CorrelationMismatch)?;
1473        if !matches!(item.kind, ItemKind::AgentMessage) {
1474            return Err(RuntimeError::CorrelationMismatch);
1475        }
1476        if item.lifecycle_complete
1477            || item.delta_bytes.saturating_add(delta.len()) > MAX_OUTPUT_BYTES
1478            || turn.output_bytes.saturating_add(delta.len()) > MAX_OUTPUT_BYTES
1479        {
1480            return Err(RuntimeError::OutputLimitExceeded);
1481        }
1482        item.delta_bytes += delta.len();
1483        turn.output_bytes += delta.len();
1484        if !delta.is_empty() {
1485            turn.visible_output = true;
1486        }
1487        Ok(RuntimeEvent::ItemDelta {
1488            thread_id,
1489            turn_id,
1490            item_id,
1491        })
1492    }
1493
1494    fn ingest_item_completed(&mut self, params: &Value) -> Result<RuntimeEvent, RuntimeError> {
1495        let thread_id = validated_id(string_at(params, &["threadId"])?)?;
1496        let turn_id = validated_id(string_at(params, &["turnId"])?)?;
1497        let item = params.get("item").ok_or(RuntimeError::InvalidInput)?;
1498        let item_type = string_at(item, &["type"])?;
1499        if item_type == "userMessage" {
1500            self.validate_user_message_notification(params, "completedAtMs")?;
1501            return Ok(RuntimeEvent::NonSemanticNotificationIgnored);
1502        }
1503        let item_id = validated_id(string_at(item, &["id"])?)?;
1504        if item_type == "mcpToolCall" {
1505            if self.tool_policy != CodexToolPolicy::CanonicalMcpLifecycle {
1506                emit_mcp_tool_policy_refusal_marker(item, McpToolLifecycle::Completed);
1507                let turn = self.turn_mut(&thread_id, &turn_id)?;
1508                turn.tool_or_side_effect = true;
1509                return Err(RuntimeError::SideEffectingItemRejected);
1510            }
1511            let server = validated_tool_name(string_at(item, &["server"])?)?;
1512            let tool = validated_tool_name(string_at(item, &["tool"])?)?;
1513            let arguments_digest =
1514                digest_json(item.get("arguments").ok_or(RuntimeError::ProtocolFailure)?)?;
1515            let status = match string_at(item, &["status"])? {
1516                "completed" if non_null(item, "result") && null_or_absent(item, "error") => {
1517                    ToolCallStatus::Completed
1518                }
1519                "failed" if non_null(item, "result") || non_null(item, "error") => {
1520                    ToolCallStatus::Failed
1521                }
1522                _ => return Err(RuntimeError::ProtocolFailure),
1523            };
1524            let turn = self.turn_mut(&thread_id, &turn_id)?;
1525            if turn.model_rerouted || !turn.lifecycle_started {
1526                return Err(RuntimeError::ProtocolFailure);
1527            }
1528            let state = turn
1529                .items
1530                .get_mut(&item_id)
1531                .ok_or(RuntimeError::CorrelationMismatch)?;
1532            match &state.kind {
1533                ItemKind::McpToolCall(binding)
1534                    if binding.item_id == item_id
1535                        && binding.server == server
1536                        && binding.tool == tool
1537                        && binding.arguments_digest == arguments_digest => {}
1538                _ => return Err(RuntimeError::CorrelationMismatch),
1539            }
1540            if state.lifecycle_complete {
1541                return Err(RuntimeError::CorrelationMismatch);
1542            }
1543            state.lifecycle_complete = true;
1544            return Ok(RuntimeEvent::ToolCallCompleted {
1545                thread_id,
1546                turn_id,
1547                item_id,
1548                status,
1549            });
1550        }
1551        if !matches!(item_type, "agentMessage" | "reasoning") {
1552            emit_side_effecting_item_type_marker(item_type);
1553            if let Ok(turn) = self.turn_mut(&thread_id, &turn_id) {
1554                turn.tool_or_side_effect = true;
1555            }
1556            return Err(RuntimeError::SideEffectingItemRejected);
1557        }
1558        let text = if item_type == "agentMessage" {
1559            Some(bounded_output(string_at(item, &["text"])?)?)
1560        } else {
1561            None
1562        };
1563        let turn = self.turn_mut(&thread_id, &turn_id)?;
1564        if turn.model_rerouted || !turn.lifecycle_started {
1565            return Err(RuntimeError::ProtocolFailure);
1566        }
1567        let item = turn
1568            .items
1569            .get_mut(&item_id)
1570            .ok_or(RuntimeError::CorrelationMismatch)?;
1571        let expected_kind = if item_type == "agentMessage" {
1572            matches!(item.kind, ItemKind::AgentMessage)
1573        } else {
1574            matches!(item.kind, ItemKind::Reasoning)
1575        };
1576        if !expected_kind || item.lifecycle_complete {
1577            return Err(RuntimeError::CorrelationMismatch);
1578        }
1579        let text_len = text.as_ref().map_or(0, String::len);
1580        if turn.output_bytes.saturating_add(text_len) > MAX_OUTPUT_BYTES {
1581            return Err(RuntimeError::OutputLimitExceeded);
1582        }
1583        turn.output_bytes += text_len;
1584        item.completed = text;
1585        item.lifecycle_complete = true;
1586        if item_type == "agentMessage" {
1587            turn.final_item = Some(item_id.clone());
1588            turn.visible_output = true;
1589        }
1590        Ok(RuntimeEvent::ItemCompleted {
1591            thread_id,
1592            turn_id,
1593            item_id,
1594        })
1595    }
1596
1597    fn validate_user_message_notification(
1598        &self,
1599        params: &Value,
1600        timestamp_key: &str,
1601    ) -> Result<(), RuntimeError> {
1602        let object = exact_object(params, &["item", "threadId", "turnId", timestamp_key])?;
1603        let thread_id = validated_id(
1604            object
1605                .get("threadId")
1606                .and_then(Value::as_str)
1607                .ok_or(RuntimeError::ProtocolFailure)?,
1608        )?;
1609        let turn_id = validated_id(
1610            object
1611                .get("turnId")
1612                .and_then(Value::as_str)
1613                .ok_or(RuntimeError::ProtocolFailure)?,
1614        )?;
1615        if object.get(timestamp_key).and_then(Value::as_u64).is_none() {
1616            return Err(RuntimeError::ProtocolFailure);
1617        }
1618        let item = exact_object(
1619            object.get("item").ok_or(RuntimeError::ProtocolFailure)?,
1620            &["type", "id", "clientId", "content"],
1621        )?;
1622        if item.get("type").and_then(Value::as_str) != Some("userMessage") {
1623            return Err(RuntimeError::ProtocolFailure);
1624        }
1625        validated_id(
1626            item.get("id")
1627                .and_then(Value::as_str)
1628                .ok_or(RuntimeError::ProtocolFailure)?,
1629        )?;
1630        validated_id(
1631            item.get("clientId")
1632                .and_then(Value::as_str)
1633                .ok_or(RuntimeError::ProtocolFailure)?,
1634        )?;
1635        let content = item
1636            .get("content")
1637            .and_then(Value::as_array)
1638            .filter(|content| content.len() == 1)
1639            .ok_or(RuntimeError::ProtocolFailure)?;
1640        let text = exact_object(&content[0], &["type", "text", "text_elements"])?;
1641        if text.get("type").and_then(Value::as_str) != Some("text")
1642            || text
1643                .get("text")
1644                .and_then(Value::as_str)
1645                .is_none_or(|text| text.len() > MAX_TEXT_BYTES || text.contains('\0'))
1646            || text
1647                .get("text_elements")
1648                .and_then(Value::as_array)
1649                .is_none_or(|elements| !elements.is_empty())
1650        {
1651            return Err(RuntimeError::ProtocolFailure);
1652        }
1653        let turn = self
1654            .turns
1655            .get(&(thread_id, turn_id))
1656            .ok_or(RuntimeError::CorrelationMismatch)?;
1657        if !turn.lifecycle_started || turn.model_rerouted {
1658            return Err(RuntimeError::ProtocolFailure);
1659        }
1660        Ok(())
1661    }
1662
1663    fn ingest_rerouted(&mut self, params: &Value) -> Result<RuntimeEvent, RuntimeError> {
1664        let thread_id = validated_id(string_at(params, &["threadId"])?)?;
1665        let turn_id = validated_id(string_at(params, &["turnId"])?)?;
1666        let from_model = validated_id(string_at(params, &["fromModel"])?)?;
1667        let to_model = validated_id(string_at(params, &["toModel"])?)?;
1668        let turn = self.turn_mut(&thread_id, &turn_id)?;
1669        if turn.model != from_model {
1670            return Err(RuntimeError::CorrelationMismatch);
1671        }
1672        turn.model_rerouted = true;
1673        Ok(RuntimeEvent::ModelReroutedFailure {
1674            thread_id,
1675            turn_id,
1676            from_model,
1677            to_model,
1678        })
1679    }
1680
1681    fn ingest_turn_completed(&mut self, params: &Value) -> Result<RuntimeEvent, RuntimeError> {
1682        let thread_id = validated_id(string_at(params, &["threadId"])?)?;
1683        let turn_id = validated_id(string_at(params, &["turn", "id"])?)?;
1684        let turn_value = params.get("turn").ok_or(RuntimeError::InvalidInput)?;
1685        let (status, failure) = match string_at(turn_value, &["status"])? {
1686            "completed" if null_or_absent(turn_value, "error") => (TerminalStatus::Completed, None),
1687            "failed" => {
1688                let error = turn_value
1689                    .get("error")
1690                    .filter(|error| error.is_object())
1691                    .ok_or(RuntimeError::ProtocolFailure)?;
1692                (TerminalStatus::Failed, Some(parse_terminal_failure(error)))
1693            }
1694            "interrupted" if null_or_absent(turn_value, "error") => {
1695                (TerminalStatus::Interrupted, None)
1696            }
1697            _ => return Err(RuntimeError::InvalidInput),
1698        };
1699        let turn = self
1700            .turns
1701            .remove(&(thread_id.clone(), turn_id.clone()))
1702            .ok_or(RuntimeError::CorrelationMismatch)?;
1703        if !turn.lifecycle_started || turn.items.values().any(|item| !item.lifecycle_complete) {
1704            return Err(RuntimeError::ProtocolFailure);
1705        }
1706        if turn.model_rerouted && status != TerminalStatus::Failed {
1707            return Err(RuntimeError::ProtocolFailure);
1708        }
1709        if status == TerminalStatus::Interrupted && !turn.interrupt_requested {
1710            return Err(RuntimeError::CorrelationMismatch);
1711        }
1712        let usage = match status {
1713            TerminalStatus::Completed => {
1714                // Completion is only valid after a fully validated cumulative
1715                // baseline and its latest per-turn projection were accepted.
1716                // The receipt intentionally projects the latter because its
1717                // public contract is per-turn rather than app-wide cumulative.
1718                turn.total_usage.ok_or(RuntimeError::ProtocolFailure)?;
1719                Some(turn.usage.ok_or(RuntimeError::ProtocolFailure)?)
1720            }
1721            TerminalStatus::Failed | TerminalStatus::Interrupted => turn.usage,
1722        };
1723        let output = turn
1724            .final_item
1725            .as_ref()
1726            .and_then(|item_id| turn.items.get(item_id))
1727            .and_then(|item| item.completed.clone());
1728        Ok(RuntimeEvent::TurnCompleted(TurnCompletionReceipt {
1729            thread_id,
1730            turn_id,
1731            selected_model: turn.model.clone(),
1732            selected_effort: turn.effort,
1733            status,
1734            output,
1735            replay: turn.replay(),
1736            failure,
1737            input_tokens: usage.map(|usage| usage.input_tokens),
1738            output_tokens: usage.map(|usage| usage.output_tokens),
1739        }))
1740    }
1741
1742    fn turn_mut(&mut self, thread_id: &str, turn_id: &str) -> Result<&mut TurnState, RuntimeError> {
1743        self.turns
1744            .get_mut(&(thread_id.to_owned(), turn_id.to_owned()))
1745            .ok_or(RuntimeError::CorrelationMismatch)
1746    }
1747
1748    fn discard_notification_first_turn(&mut self, started: Option<&PendingTurnStarted>) {
1749        if let Some(started) = started {
1750            self.turns
1751                .remove(&(started.thread_id.clone(), started.turn_id.clone()));
1752        }
1753    }
1754}
1755
1756fn side_effecting_item_type_marker(item_type: &str) -> String {
1757    let item_type_sha256 = Sha256::digest(item_type.as_bytes())
1758        .iter()
1759        .map(|byte| format!("{byte:02x}"))
1760        .collect::<String>();
1761    format!("steadymux-owned-child: side-effecting-item-type-sha256={item_type_sha256}")
1762}
1763
1764fn emit_side_effecting_item_type_marker(item_type: &str) {
1765    eprintln!("{}", side_effecting_item_type_marker(item_type));
1766}
1767
1768#[derive(Clone, Copy)]
1769enum McpToolLifecycle {
1770    Started,
1771    Completed,
1772}
1773
1774fn mcp_tool_policy_refusal_marker(item: &Value, lifecycle: McpToolLifecycle) -> String {
1775    let lifecycle = match lifecycle {
1776        McpToolLifecycle::Started => "started",
1777        McpToolLifecycle::Completed => "completed",
1778    };
1779    let status = match item.get("status").and_then(Value::as_str) {
1780        Some("inProgress") => "in-progress",
1781        Some("completed") => "completed",
1782        Some("failed") => "failed",
1783        _ => "unknown",
1784    };
1785    let server = mcp_tool_identity_commitment(item.get("server").and_then(Value::as_str));
1786    let tool = mcp_tool_identity_commitment(item.get("tool").and_then(Value::as_str));
1787    format!(
1788        "steadymux-owned-child: side-effecting-item-refused=mcp-tool-policy \
1789         lifecycle={lifecycle} status={status} server-sha256={server} tool-sha256={tool}"
1790    )
1791}
1792
1793fn mcp_tool_identity_commitment(value: Option<&str>) -> String {
1794    let Some(value) = value else {
1795        return "missing-or-invalid".to_owned();
1796    };
1797    if validated_tool_name(value).is_err() {
1798        return "missing-or-invalid".to_owned();
1799    }
1800    Sha256::digest(value.as_bytes())
1801        .iter()
1802        .map(|byte| format!("{byte:02x}"))
1803        .collect()
1804}
1805
1806fn emit_mcp_tool_policy_refusal_marker(item: &Value, lifecycle: McpToolLifecycle) {
1807    eprintln!("{}", mcp_tool_policy_refusal_marker(item, lifecycle));
1808}
1809
1810fn encode_line(value: &Value) -> Result<String, RuntimeError> {
1811    let mut encoded = serde_json::to_string(value).map_err(|_| RuntimeError::InvalidInput)?;
1812    encoded.push('\n');
1813    Ok(encoded)
1814}
1815
1816fn validated_id(value: &str) -> Result<String, RuntimeError> {
1817    if value.is_empty()
1818        || value.len() > MAX_ID_BYTES
1819        || !value
1820            .bytes()
1821            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
1822    {
1823        return Err(RuntimeError::InvalidInput);
1824    }
1825    Ok(value.to_owned())
1826}
1827
1828fn bounded_text(value: &str) -> Result<String, RuntimeError> {
1829    if value.len() > MAX_ID_BYTES || value.chars().any(char::is_control) {
1830        return Err(RuntimeError::InvalidInput);
1831    }
1832    Ok(value.to_owned())
1833}
1834
1835fn bounded_output(value: &str) -> Result<String, RuntimeError> {
1836    if value.len() > MAX_OUTPUT_BYTES {
1837        return Err(RuntimeError::OutputLimitExceeded);
1838    }
1839    Ok(value.to_owned())
1840}
1841
1842fn validated_instruction(value: Option<&str>) -> Result<Option<String>, RuntimeError> {
1843    let Some(value) = value else {
1844        return Ok(None);
1845    };
1846    if value.len() > MAX_TEXT_BYTES || value.contains('\0') {
1847        return Err(RuntimeError::InvalidInput);
1848    }
1849    if looks_like_secret(value) {
1850        return Err(RuntimeError::SensitiveInputRejected);
1851    }
1852    Ok(Some(value.to_owned()))
1853}
1854
1855fn validated_tool_name(value: &str) -> Result<String, RuntimeError> {
1856    if value.is_empty() || value.len() > MAX_ID_BYTES || value.chars().any(char::is_control) {
1857        return Err(RuntimeError::InvalidInput);
1858    }
1859    Ok(value.to_owned())
1860}
1861
1862fn valid_opaque_control_value(value: Option<&Value>) -> bool {
1863    value.and_then(Value::as_str).is_some_and(|value| {
1864        !value.is_empty() && value.len() <= MAX_ID_BYTES && !value.chars().any(char::is_control)
1865    })
1866}
1867
1868fn validate_ephemeral_empty_thread(value: &Value) -> Result<String, RuntimeError> {
1869    const REQUIRED: [&str; 12] = [
1870        "cliVersion",
1871        "createdAt",
1872        "cwd",
1873        "ephemeral",
1874        "id",
1875        "modelProvider",
1876        "preview",
1877        "sessionId",
1878        "source",
1879        "status",
1880        "turns",
1881        "updatedAt",
1882    ];
1883    const OPTIONAL: [&str; 14] = [
1884        "agentNickname",
1885        "agentRole",
1886        "canAcceptDirectInput",
1887        "extra",
1888        "forkedFromId",
1889        "gitInfo",
1890        "historyMode",
1891        "name",
1892        "parentThreadId",
1893        "path",
1894        "recencyAt",
1895        "section",
1896        "sectionEnteredAt",
1897        "threadSource",
1898    ];
1899    const NULL_ONLY: [&str; 11] = [
1900        "agentNickname",
1901        "agentRole",
1902        "extra",
1903        "forkedFromId",
1904        "gitInfo",
1905        "name",
1906        "parentThreadId",
1907        "path",
1908        "section",
1909        "sectionEnteredAt",
1910        "threadSource",
1911    ];
1912
1913    let thread = value.as_object().ok_or(RuntimeError::ProtocolFailure)?;
1914    let missing_keys = REQUIRED
1915        .iter()
1916        .filter(|key| !thread.contains_key(**key))
1917        .copied()
1918        .collect::<Vec<_>>();
1919    let unknown_keys = thread
1920        .keys()
1921        .filter(|key| !REQUIRED.contains(&key.as_str()) && !OPTIONAL.contains(&key.as_str()))
1922        .map(String::as_str)
1923        .collect::<Vec<_>>();
1924    let refusal = if !missing_keys.is_empty() || !unknown_keys.is_empty() {
1925        for key in missing_keys.iter().chain(unknown_keys.iter()) {
1926            let key_sha256 = Sha256::digest(key.as_bytes())
1927                .iter()
1928                .map(|byte| format!("{byte:02x}"))
1929                .collect::<String>();
1930            eprintln!("steadymux-owned-child: thread-started-key-sha256={key_sha256}");
1931        }
1932        eprintln!(
1933            "steadymux-owned-child: thread-started-key-counts=missing-{}-unknown-{}",
1934            missing_keys.len(),
1935            unknown_keys.len()
1936        );
1937        Some("keys")
1938    } else if NULL_ONLY
1939        .iter()
1940        .any(|key| thread.get(*key).is_some_and(|value| !value.is_null()))
1941    {
1942        Some("null-only")
1943    } else if thread.get("ephemeral").and_then(Value::as_bool) != Some(true)
1944        || thread.get("modelProvider").and_then(Value::as_str) != Some("openai")
1945        || !matches!(
1946            thread.get("source").and_then(Value::as_str),
1947            Some("appServer" | "vscode")
1948        )
1949    {
1950        Some("core")
1951    } else if thread.get("cliVersion").and_then(Value::as_str)
1952        != Some(CURRENT_STANDALONE_CLI_VERSION)
1953    {
1954        Some("version")
1955    } else if !valid_idle_thread_status(thread.get("status")) {
1956        Some("status")
1957    } else if thread
1958        .get("turns")
1959        .and_then(Value::as_array)
1960        .is_none_or(|turns| !turns.is_empty())
1961    {
1962        Some("turns")
1963    } else if thread
1964        .get("preview")
1965        .and_then(Value::as_str)
1966        .is_none_or(|preview| preview.len() > MAX_TEXT_BYTES)
1967    {
1968        Some("preview")
1969    } else if !valid_thread_path(thread.get("cwd")) {
1970        Some("cwd")
1971    } else if thread.get("recencyAt").is_some_and(|value| {
1972        !value.is_null() && value.as_i64().is_none_or(|timestamp| timestamp < 0)
1973    }) || thread
1974        .get("canAcceptDirectInput")
1975        .is_some_and(|value| value.as_bool() != Some(true))
1976        || thread
1977            .get("historyMode")
1978            .is_some_and(|value| !matches!(value.as_str(), Some("legacy" | "paginated")))
1979    {
1980        Some("optional")
1981    } else {
1982        None
1983    };
1984    if let Some(class) = refusal {
1985        eprintln!("steadymux-owned-child: thread-started-refused={class}");
1986        return Err(RuntimeError::ProtocolFailure);
1987    }
1988
1989    let created_at = thread
1990        .get("createdAt")
1991        .and_then(Value::as_i64)
1992        .filter(|timestamp| *timestamp >= 0)
1993        .ok_or_else(|| {
1994            eprintln!("steadymux-owned-child: thread-started-refused=timestamps");
1995            RuntimeError::ProtocolFailure
1996        })?;
1997    thread
1998        .get("updatedAt")
1999        .and_then(Value::as_i64)
2000        .filter(|timestamp| *timestamp >= created_at)
2001        .ok_or_else(|| {
2002            eprintln!("steadymux-owned-child: thread-started-refused=timestamps");
2003            RuntimeError::ProtocolFailure
2004        })?;
2005    validated_id(
2006        thread
2007            .get("sessionId")
2008            .and_then(Value::as_str)
2009            .ok_or_else(|| {
2010                eprintln!("steadymux-owned-child: thread-started-refused=ids");
2011                RuntimeError::ProtocolFailure
2012            })?,
2013    )?;
2014    validated_id(thread.get("id").and_then(Value::as_str).ok_or_else(|| {
2015        eprintln!("steadymux-owned-child: thread-started-refused=ids");
2016        RuntimeError::ProtocolFailure
2017    })?)
2018}
2019
2020fn valid_idle_thread_status(value: Option<&Value>) -> bool {
2021    value.and_then(Value::as_object).is_some_and(|status| {
2022        status.len() == 1 && status.get("type").and_then(Value::as_str) == Some("idle")
2023    })
2024}
2025
2026fn valid_thread_path(value: Option<&Value>) -> bool {
2027    let Some(value) = value.and_then(Value::as_str) else {
2028        return false;
2029    };
2030    if value.is_empty()
2031        || value.len() > MAX_THREAD_PATH_BYTES
2032        || value.chars().any(char::is_control)
2033    {
2034        return false;
2035    }
2036    let path = Path::new(value);
2037    path.is_absolute()
2038        && path
2039            .components()
2040            .all(|component| matches!(component, Component::RootDir | Component::Normal(_)))
2041}
2042
2043fn valid_json_rpc_error_response(value: &Value) -> bool {
2044    let Some(response) = value.as_object() else {
2045        return false;
2046    };
2047    if !(response.len() == 2 || response.len() == 3)
2048        || !response.contains_key("id")
2049        || !response.contains_key("error")
2050        || !response
2051            .keys()
2052            .all(|key| matches!(key.as_str(), "id" | "error" | "jsonrpc"))
2053        || response
2054            .get("jsonrpc")
2055            .is_some_and(|version| version.as_str() != Some("2.0"))
2056    {
2057        return false;
2058    }
2059    let Some(error) = response.get("error").and_then(Value::as_object) else {
2060        return false;
2061    };
2062    (error.len() == 2 || error.len() == 3)
2063        && error.get("code").and_then(Value::as_i64).is_some()
2064        && error.get("message").and_then(Value::as_str).is_some()
2065        && error
2066            .keys()
2067            .all(|key| matches!(key.as_str(), "code" | "message" | "data"))
2068}
2069
2070fn exact_object<'a>(
2071    value: &'a Value,
2072    expected_keys: &[&str],
2073) -> Result<&'a serde_json::Map<String, Value>, RuntimeError> {
2074    let object = value.as_object().ok_or(RuntimeError::ProtocolFailure)?;
2075    if object.len() != expected_keys.len()
2076        || expected_keys.iter().any(|key| !object.contains_key(*key))
2077    {
2078        return Err(RuntimeError::ProtocolFailure);
2079    }
2080    Ok(object)
2081}
2082
2083fn correlated_reasoning_ids(
2084    object: &serde_json::Map<String, Value>,
2085    index_key: &str,
2086) -> Result<(String, String, String), RuntimeError> {
2087    let thread_id = validated_id(
2088        object
2089            .get("threadId")
2090            .and_then(Value::as_str)
2091            .ok_or(RuntimeError::ProtocolFailure)?,
2092    )?;
2093    let turn_id = validated_id(
2094        object
2095            .get("turnId")
2096            .and_then(Value::as_str)
2097            .ok_or(RuntimeError::ProtocolFailure)?,
2098    )?;
2099    let item_id = validated_id(
2100        object
2101            .get("itemId")
2102            .and_then(Value::as_str)
2103            .ok_or(RuntimeError::ProtocolFailure)?,
2104    )?;
2105    let valid_index = object
2106        .get(index_key)
2107        .and_then(Value::as_u64)
2108        .is_some_and(|index| index < MAX_ITEMS_PER_TURN as u64);
2109    if !valid_index {
2110        return Err(RuntimeError::ProtocolFailure);
2111    }
2112    Ok((thread_id, turn_id, item_id))
2113}
2114
2115fn token_usage_refusal(class: &'static str) -> RuntimeError {
2116    eprintln!("steadymux-owned-child: token-usage-refused={class}");
2117    RuntimeError::ProtocolFailure
2118}
2119
2120#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2121enum TokenUsageCorrelationRefusal {
2122    MissingPair,
2123    Rerouted,
2124    PriorUsage,
2125}
2126
2127#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2128enum PriorUsageDirection {
2129    Unavailable,
2130    Same,
2131    MonotonicNondecreasing,
2132    DecreaseOrMixed,
2133}
2134
2135#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2136enum TotalUsageRelativeToLast {
2137    Equal,
2138    TotalNondecreasing,
2139    TotalDecreaseOrMixed,
2140}
2141
2142impl TotalUsageRelativeToLast {
2143    const fn as_str(self) -> &'static str {
2144        match self {
2145            Self::Equal => "equal",
2146            Self::TotalNondecreasing => "total-nondecreasing",
2147            Self::TotalDecreaseOrMixed => "total-decrease-or-mixed",
2148        }
2149    }
2150}
2151
2152impl PriorUsageDirection {
2153    const fn as_str(self) -> &'static str {
2154        match self {
2155            Self::Unavailable => "unavailable",
2156            Self::Same => "same",
2157            Self::MonotonicNondecreasing => "monotonic-nondecreasing",
2158            Self::DecreaseOrMixed => "decrease-or-mixed",
2159        }
2160    }
2161}
2162
2163fn prior_usage_direction(
2164    previous: TurnUsageCounts,
2165    incoming: TurnUsageCounts,
2166) -> PriorUsageDirection {
2167    if previous == incoming {
2168        PriorUsageDirection::Same
2169    } else if incoming.input_tokens >= previous.input_tokens
2170        && incoming.output_tokens >= previous.output_tokens
2171    {
2172        PriorUsageDirection::MonotonicNondecreasing
2173    } else {
2174        PriorUsageDirection::DecreaseOrMixed
2175    }
2176}
2177
2178fn prior_usage_direction_marker(direction: PriorUsageDirection) -> String {
2179    format!(
2180        "steadymux-owned-child: token-usage-prior-direction={}",
2181        direction.as_str()
2182    )
2183}
2184
2185fn emit_prior_usage_direction_marker(direction: PriorUsageDirection) {
2186    eprintln!("{}", prior_usage_direction_marker(direction));
2187}
2188
2189fn total_usage_relative_to_last(
2190    last: TurnUsageCounts,
2191    total: CumulativeTurnUsageCounts,
2192) -> TotalUsageRelativeToLast {
2193    if total.input_tokens == last.input_tokens && total.output_tokens == last.output_tokens {
2194        TotalUsageRelativeToLast::Equal
2195    } else if total.input_tokens >= last.input_tokens && total.output_tokens >= last.output_tokens {
2196        TotalUsageRelativeToLast::TotalNondecreasing
2197    } else {
2198        TotalUsageRelativeToLast::TotalDecreaseOrMixed
2199    }
2200}
2201
2202fn cumulative_usage_direction(
2203    previous: CumulativeTurnUsageCounts,
2204    incoming: CumulativeTurnUsageCounts,
2205) -> PriorUsageDirection {
2206    if previous == incoming {
2207        PriorUsageDirection::Same
2208    } else if incoming.cached_input_tokens >= previous.cached_input_tokens
2209        && cache_write_non_decreasing(
2210            previous.cache_write_input_tokens,
2211            incoming.cache_write_input_tokens,
2212        )
2213        && incoming.input_tokens >= previous.input_tokens
2214        && incoming.output_tokens >= previous.output_tokens
2215        && incoming.reasoning_output_tokens >= previous.reasoning_output_tokens
2216        && incoming.total_tokens >= previous.total_tokens
2217    {
2218        PriorUsageDirection::MonotonicNondecreasing
2219    } else {
2220        PriorUsageDirection::DecreaseOrMixed
2221    }
2222}
2223
2224/// `cacheWriteInputTokens` is an optional cumulative field. It is compared
2225/// only when both frames report it; an omitted value is not treated as a
2226/// regression against a previously reported positive count.
2227fn cache_write_non_decreasing(previous: Option<u64>, incoming: Option<u64>) -> bool {
2228    match (previous, incoming) {
2229        (Some(previous), Some(incoming)) => incoming >= previous,
2230        _ => true,
2231    }
2232}
2233
2234fn prior_usage_projection_marker(
2235    total_relative_to_last: TotalUsageRelativeToLast,
2236    total_direction: PriorUsageDirection,
2237) -> String {
2238    format!(
2239        "steadymux-owned-child: token-usage-prior-projections total-vs-last={} total-vs-prior={}",
2240        total_relative_to_last.as_str(),
2241        total_direction.as_str(),
2242    )
2243}
2244
2245fn emit_prior_usage_projection_marker(
2246    total_relative_to_last: TotalUsageRelativeToLast,
2247    total_direction: PriorUsageDirection,
2248) {
2249    eprintln!(
2250        "{}",
2251        prior_usage_projection_marker(total_relative_to_last, total_direction)
2252    );
2253}
2254
2255impl TokenUsageCorrelationRefusal {
2256    const fn as_str(self) -> &'static str {
2257        match self {
2258            Self::MissingPair => "missing-pair",
2259            Self::Rerouted => "rerouted",
2260            Self::PriorUsage => "prior-usage",
2261        }
2262    }
2263}
2264
2265fn emit_token_usage_correlation_marker(
2266    reason: TokenUsageCorrelationRefusal,
2267    incoming_thread_id: &str,
2268    incoming_turn_id: &str,
2269    turns: &BTreeMap<(String, String), TurnState>,
2270) {
2271    eprintln!(
2272        "{}",
2273        token_usage_correlation_marker(reason, incoming_thread_id, incoming_turn_id, turns)
2274    );
2275}
2276
2277fn token_usage_correlation_marker(
2278    reason: TokenUsageCorrelationRefusal,
2279    incoming_thread_id: &str,
2280    incoming_turn_id: &str,
2281    turns: &BTreeMap<(String, String), TurnState>,
2282) -> String {
2283    fn sha256_hex(value: &[u8]) -> String {
2284        Sha256::digest(value)
2285            .iter()
2286            .map(|byte| format!("{byte:02x}"))
2287            .collect()
2288    }
2289
2290    fn update_id(hasher: &mut Sha256, id: &str) {
2291        hasher.update((id.len() as u64).to_be_bytes());
2292        hasher.update(id.as_bytes());
2293    }
2294
2295    fn id_commitment(domain: &[u8], ids: impl IntoIterator<Item = String>) -> String {
2296        let mut digest = Sha256::new();
2297        digest.update(domain);
2298        for id in ids {
2299            update_id(&mut digest, &id);
2300        }
2301        digest
2302            .finalize()
2303            .iter()
2304            .map(|byte| format!("{byte:02x}"))
2305            .collect()
2306    }
2307
2308    let mut known_pairs = Sha256::new();
2309    known_pairs.update(b"steadymux.token-usage-known-pairs.v1\0");
2310    for (thread_id, turn_id) in turns.keys() {
2311        update_id(&mut known_pairs, thread_id);
2312        update_id(&mut known_pairs, turn_id);
2313    }
2314    let known_pairs_sha256 = known_pairs
2315        .finalize()
2316        .iter()
2317        .map(|byte| format!("{byte:02x}"))
2318        .collect::<String>();
2319    let known_active_threads_sha256 = id_commitment(
2320        b"steadymux.token-usage-known-active-threads.v1\0",
2321        turns
2322            .keys()
2323            .map(|(thread_id, _)| thread_id.clone())
2324            .collect::<BTreeSet<_>>(),
2325    );
2326    let known_active_turns_sha256 = id_commitment(
2327        b"steadymux.token-usage-known-active-turns.v1\0",
2328        turns
2329            .keys()
2330            .map(|(_, turn_id)| turn_id.clone())
2331            .collect::<BTreeSet<_>>(),
2332    );
2333
2334    format!(
2335        "steadymux-owned-child: token-usage-correlation reason={} incoming-thread-sha256={} incoming-turn-sha256={} known-active-threads-sha256={known_active_threads_sha256} known-active-turns-sha256={known_active_turns_sha256} known-active-pairs-sha256={known_pairs_sha256} known-active-pair-count={}",
2336        reason.as_str(),
2337        sha256_hex(incoming_thread_id.as_bytes()),
2338        sha256_hex(incoming_turn_id.as_bytes()),
2339        turns.len(),
2340    )
2341}
2342
2343#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2344enum TokenBreakdownValidation {
2345    Valid,
2346    Shape,
2347    Keys,
2348    Counts,
2349}
2350
2351fn validated_last_turn_usage(value: Option<&Value>) -> Result<TurnUsageCounts, RuntimeError> {
2352    let Some(usage) = value.and_then(Value::as_object) else {
2353        return Err(token_usage_refusal("usage-keys"));
2354    };
2355    if !(usage.len() == 2 || usage.len() == 3)
2356        || !usage.contains_key("last")
2357        || !usage.contains_key("total")
2358        || !usage
2359            .keys()
2360            .all(|key| matches!(key.as_str(), "last" | "total" | "modelContextWindow"))
2361    {
2362        return Err(token_usage_refusal("usage-keys"));
2363    }
2364    match valid_token_breakdown(usage.get("last")) {
2365        TokenBreakdownValidation::Valid => {}
2366        TokenBreakdownValidation::Shape => return Err(token_usage_refusal("last-shape")),
2367        TokenBreakdownValidation::Keys => {
2368            emit_token_breakdown_key_diagnostics(usage.get("last"));
2369            return Err(token_usage_refusal("last-counts"));
2370        }
2371        TokenBreakdownValidation::Counts => return Err(token_usage_refusal("last-counts")),
2372    }
2373    match valid_token_breakdown(usage.get("total")) {
2374        TokenBreakdownValidation::Valid => {}
2375        TokenBreakdownValidation::Keys => {
2376            emit_token_breakdown_key_diagnostics(usage.get("total"));
2377            return Err(token_usage_refusal("total-shape"));
2378        }
2379        TokenBreakdownValidation::Shape | TokenBreakdownValidation::Counts => {
2380            return Err(token_usage_refusal("total-shape"));
2381        }
2382    }
2383    if !usage
2384        .get("modelContextWindow")
2385        .is_none_or(|value| value.is_null() || value.as_i64().is_some_and(|window| window >= 0))
2386    {
2387        return Err(token_usage_refusal("context-window"));
2388    }
2389    let last = usage
2390        .get("last")
2391        .and_then(Value::as_object)
2392        .ok_or_else(|| token_usage_refusal("last-shape"))?;
2393    let input_tokens = last
2394        .get("inputTokens")
2395        .and_then(Value::as_i64)
2396        .and_then(|tokens| u64::try_from(tokens).ok())
2397        .ok_or_else(|| token_usage_refusal("last-counts"))?;
2398    let output_tokens = last
2399        .get("outputTokens")
2400        .and_then(Value::as_i64)
2401        .and_then(|tokens| u64::try_from(tokens).ok())
2402        .ok_or_else(|| token_usage_refusal("last-counts"))?;
2403    Ok(TurnUsageCounts {
2404        input_tokens,
2405        output_tokens,
2406    })
2407}
2408
2409/// Extracts the complete validated cumulative projection. This remains private
2410/// runtime state: completion receipts deliberately expose only the latest
2411/// per-turn `last` projection.
2412fn validated_total_turn_usage(
2413    value: Option<&Value>,
2414) -> Result<CumulativeTurnUsageCounts, RuntimeError> {
2415    let total = value
2416        .and_then(Value::as_object)
2417        .and_then(|usage| usage.get("total"))
2418        .and_then(Value::as_object)
2419        .ok_or_else(|| token_usage_refusal("total-shape"))?;
2420    let count = |key| {
2421        total
2422            .get(key)
2423            .and_then(Value::as_i64)
2424            .and_then(|tokens| u64::try_from(tokens).ok())
2425            .ok_or_else(|| token_usage_refusal("total-shape"))
2426    };
2427    Ok(CumulativeTurnUsageCounts {
2428        cached_input_tokens: count("cachedInputTokens")?,
2429        cache_write_input_tokens: total
2430            .get("cacheWriteInputTokens")
2431            .map(|value| {
2432                value
2433                    .as_i64()
2434                    .and_then(|tokens| u64::try_from(tokens).ok())
2435                    .ok_or_else(|| token_usage_refusal("total-shape"))
2436            })
2437            .transpose()?,
2438        input_tokens: count("inputTokens")?,
2439        output_tokens: count("outputTokens")?,
2440        reasoning_output_tokens: count("reasoningOutputTokens")?,
2441        total_tokens: count("totalTokens")?,
2442    })
2443}
2444
2445fn valid_token_breakdown(value: Option<&Value>) -> TokenBreakdownValidation {
2446    const REQUIRED: [&str; 5] = [
2447        "cachedInputTokens",
2448        "inputTokens",
2449        "outputTokens",
2450        "reasoningOutputTokens",
2451        "totalTokens",
2452    ];
2453    let Some(breakdown) = value.and_then(Value::as_object) else {
2454        return TokenBreakdownValidation::Shape;
2455    };
2456    if !(breakdown.len() == REQUIRED.len() || breakdown.len() == REQUIRED.len() + 1) {
2457        return TokenBreakdownValidation::Keys;
2458    }
2459    if !breakdown
2460        .keys()
2461        .all(|key| REQUIRED.contains(&key.as_str()) || key.as_str() == "cacheWriteInputTokens")
2462    {
2463        return TokenBreakdownValidation::Keys;
2464    }
2465    if REQUIRED.iter().any(|key| {
2466        breakdown
2467            .get(*key)
2468            .and_then(Value::as_i64)
2469            .is_none_or(|tokens| tokens < 0)
2470    }) {
2471        return TokenBreakdownValidation::Counts;
2472    }
2473    if !breakdown
2474        .get("cacheWriteInputTokens")
2475        .is_none_or(|value| value.as_i64().is_some_and(|tokens| tokens >= 0))
2476    {
2477        return TokenBreakdownValidation::Counts;
2478    }
2479    TokenBreakdownValidation::Valid
2480}
2481
2482fn emit_token_breakdown_key_diagnostics(value: Option<&Value>) {
2483    const REQUIRED: [&str; 5] = [
2484        "cachedInputTokens",
2485        "inputTokens",
2486        "outputTokens",
2487        "reasoningOutputTokens",
2488        "totalTokens",
2489    ];
2490    let Some(breakdown) = value.and_then(Value::as_object) else {
2491        return;
2492    };
2493    let required = REQUIRED.into_iter().collect::<BTreeSet<_>>();
2494    let allowed = required
2495        .iter()
2496        .copied()
2497        .chain(std::iter::once("cacheWriteInputTokens"))
2498        .collect::<BTreeSet<_>>();
2499    let actual = breakdown
2500        .keys()
2501        .map(String::as_str)
2502        .collect::<BTreeSet<_>>();
2503    let missing = required.difference(&actual).copied().collect::<Vec<_>>();
2504    let unknown = actual.difference(&allowed).copied().collect::<Vec<_>>();
2505    for key in missing.iter().chain(unknown.iter()) {
2506        let key_sha256 = Sha256::digest(key.as_bytes())
2507            .iter()
2508            .map(|byte| format!("{byte:02x}"))
2509            .collect::<String>();
2510        eprintln!("steadymux-owned-child: token-usage-breakdown-key-sha256={key_sha256}");
2511    }
2512    eprintln!(
2513        "steadymux-owned-child: token-usage-breakdown-key-counts=missing-{}-unknown-{}",
2514        missing.len(),
2515        unknown.len()
2516    );
2517}
2518
2519fn validated_notification_method(value: &str) -> Result<String, RuntimeError> {
2520    if value.is_empty()
2521        || value.len() > MAX_ID_BYTES
2522        || !value.bytes().all(|byte| {
2523            byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'-' | b'_' | b'.' | b':')
2524        })
2525    {
2526        return Err(RuntimeError::ProtocolFailure);
2527    }
2528    Ok(value.to_owned())
2529}
2530
2531/// Return a fixed, content-free provenance marker for a response whose pending
2532/// request did not correlate. The response ID and payload intentionally never
2533/// enter this diagnostic surface.
2534fn correlation_mismatch_response_marker() -> &'static str {
2535    "steadymux-owned-child: correlation-mismatch source=response class=correlation"
2536}
2537
2538/// Return a fixed-class, content-free provenance marker for a notification
2539/// whose state transition did not correlate. The app-server method itself is
2540/// committed only by SHA-256 so logs cannot disclose server protocol details.
2541fn correlation_mismatch_notification_marker(method: &str) -> String {
2542    let handler = match method {
2543        "thread/tokenUsage/updated" => "usage",
2544        "thread/started" | "thread/status/changed" => "thread",
2545        "turn/started" | "turn/completed" => "turn",
2546        method if method.starts_with("item/") => "item",
2547        method if method.starts_with("hook/") => "hook",
2548        method if method.starts_with("mcpServer/") => "mcp",
2549        _ => "control",
2550    };
2551    let method_sha256 = Sha256::digest(method.as_bytes())
2552        .iter()
2553        .map(|byte| format!("{byte:02x}"))
2554        .collect::<String>();
2555    format!(
2556        "steadymux-owned-child: correlation-mismatch source=notification handler={handler} mismatch=correlation method-sha256={method_sha256}"
2557    )
2558}
2559
2560fn digest_json(value: &Value) -> Result<[u8; 32], RuntimeError> {
2561    let encoded = serde_json::to_vec(value).map_err(|_| RuntimeError::InvalidInput)?;
2562    Ok(Sha256::digest(encoded).into())
2563}
2564
2565fn app_server_version_from_user_agent(user_agent: &str) -> Option<&str> {
2566    let identity = user_agent.split_ascii_whitespace().next()?;
2567    let (originator, version) = identity.split_once('/')?;
2568    if originator != "steadymux"
2569        || version.is_empty()
2570        || version.len() > MAX_ID_BYTES
2571        || version.chars().any(char::is_control)
2572    {
2573        return None;
2574    }
2575    Some(version)
2576}
2577
2578fn null_or_absent(value: &Value, key: &str) -> bool {
2579    value.get(key).is_none_or(Value::is_null)
2580}
2581
2582fn non_null(value: &Value, key: &str) -> bool {
2583    value.get(key).is_some_and(|field| !field.is_null())
2584}
2585
2586fn parse_terminal_failure(error: &Value) -> TerminalFailureClass {
2587    let Some(info) = error.get("codexErrorInfo") else {
2588        return TerminalFailureClass::Unclassified;
2589    };
2590    let discriminator = match info {
2591        Value::String(value) => Some(value.as_str()),
2592        Value::Object(object) => object
2593            .get("type")
2594            .and_then(Value::as_str)
2595            .or_else(|| object.keys().next().map(String::as_str)),
2596        _ => None,
2597    };
2598    let Some(discriminator) = discriminator else {
2599        return TerminalFailureClass::Other;
2600    };
2601    let normalized = discriminator.to_ascii_lowercase();
2602    if normalized.contains("contextwindow") {
2603        TerminalFailureClass::ContextWindow
2604    } else if normalized.contains("usage")
2605        || normalized.contains("budget")
2606        || normalized.contains("ratelimit")
2607    {
2608        TerminalFailureClass::UsageLimit
2609    } else if normalized.contains("connection")
2610        || normalized.contains("stream")
2611        || normalized.contains("http")
2612    {
2613        TerminalFailureClass::Transport
2614    } else if normalized.contains("badrequest") || normalized.contains("invalidrequest") {
2615        TerminalFailureClass::InvalidRequest
2616    } else if normalized.contains("unauthorized") || normalized.contains("authentication") {
2617        TerminalFailureClass::Unauthorized
2618    } else if normalized.contains("sandbox") || normalized.contains("permission") {
2619        TerminalFailureClass::Sandbox
2620    } else if normalized.contains("internal") {
2621        TerminalFailureClass::Internal
2622    } else {
2623        TerminalFailureClass::Other
2624    }
2625}
2626
2627fn string_at<'a>(value: &'a Value, path: &[&str]) -> Result<&'a str, RuntimeError> {
2628    let mut cursor = value;
2629    for key in path {
2630        cursor = cursor.get(*key).ok_or(RuntimeError::InvalidInput)?;
2631    }
2632    cursor.as_str().ok_or(RuntimeError::InvalidInput)
2633}
2634
2635fn parse_effort(value: &str) -> Result<ReasoningEffort, RuntimeError> {
2636    match value {
2637        "minimal" => Ok(ReasoningEffort::Minimal),
2638        "low" => Ok(ReasoningEffort::Low),
2639        "medium" => Ok(ReasoningEffort::Medium),
2640        "high" => Ok(ReasoningEffort::High),
2641        "xhigh" => Ok(ReasoningEffort::Xhigh),
2642        "max" => Ok(ReasoningEffort::Max),
2643        "ultra" => Ok(ReasoningEffort::Ultra),
2644        _ => Err(RuntimeError::InvalidInput),
2645    }
2646}
2647
2648fn parse_window(value: Option<&Value>) -> Result<Option<RateLimitWindowProjection>, RuntimeError> {
2649    let Some(value) = value else { return Ok(None) };
2650    if value.is_null() {
2651        return Ok(None);
2652    }
2653    let used_percent = value
2654        .get("usedPercent")
2655        .and_then(Value::as_u64)
2656        .ok_or(RuntimeError::InvalidInput)?;
2657    if used_percent > 100 {
2658        return Err(RuntimeError::InvalidInput);
2659    }
2660    Ok(Some(RateLimitWindowProjection {
2661        used_percent: used_percent as u8,
2662        window_duration_minutes: value.get("windowDurationMins").and_then(Value::as_u64),
2663        resets_at_unix_seconds: value.get("resetsAt").and_then(Value::as_u64),
2664    }))
2665}
2666
2667fn contains_forbidden_sensitive_material(value: &Value) -> bool {
2668    match value {
2669        Value::Object(object) => object.iter().any(|(key, value)| {
2670            let normalized = key.to_ascii_lowercase();
2671            TOKEN_KEYS
2672                .iter()
2673                .any(|forbidden| normalized.contains(forbidden))
2674                || contains_forbidden_sensitive_material(value)
2675        }),
2676        Value::Array(values) => values.iter().any(contains_forbidden_sensitive_material),
2677        Value::String(value) => looks_like_secret(value),
2678        _ => false,
2679    }
2680}
2681
2682fn looks_like_secret(value: &str) -> bool {
2683    let lower = value.to_ascii_lowercase();
2684    lower.contains("bearer ")
2685        || lower.contains("-----begin private key-----")
2686        || lower.contains("-----begin rsa private key-----")
2687        || lower.contains("github_pat_")
2688        || lower.contains("xoxb-")
2689        || lower.contains("xoxp-")
2690        || value
2691            .split(|character: char| character.is_whitespace() || matches!(character, '"' | '\''))
2692            .any(|token| {
2693                (token.starts_with("sk-") && token.len() >= 20)
2694                    || (token.starts_with("AKIA")
2695                        && token.len() == 20
2696                        && token
2697                            .bytes()
2698                            .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit()))
2699            })
2700}
2701
2702#[cfg(test)]
2703mod notification_tests {
2704    use super::*;
2705    use serde_json::json;
2706
2707    fn ingest(runtime: &mut CodexAgentRuntime, value: Value) -> Result<RuntimeEvent, RuntimeError> {
2708        runtime.ingest_line(
2709            &serde_json::to_vec(&value).expect("serialize app-server notification fixture"),
2710        )
2711    }
2712
2713    fn active_reasoning_runtime() -> CodexAgentRuntime {
2714        let thread_id = "thr-notification".to_owned();
2715        let turn_id = "turn-notification".to_owned();
2716        let item_id = "reasoning-notification".to_owned();
2717        let mut threads = BTreeMap::new();
2718        threads.insert(
2719            thread_id.clone(),
2720            ThreadSelection {
2721                model: "gpt-5.6-luna".to_owned(),
2722                effort: ReasoningEffort::Low,
2723            },
2724        );
2725        let mut items = BTreeMap::new();
2726        items.insert(
2727            item_id,
2728            ItemState {
2729                kind: ItemKind::Reasoning,
2730                delta_bytes: 0,
2731                completed: None,
2732                lifecycle_complete: false,
2733            },
2734        );
2735        let mut turns = BTreeMap::new();
2736        turns.insert(
2737            (thread_id, turn_id),
2738            TurnState {
2739                model: "gpt-5.6-luna".to_owned(),
2740                effort: ReasoningEffort::Low,
2741                items,
2742                final_item: None,
2743                visible_output: false,
2744                tool_or_side_effect: false,
2745                model_rerouted: false,
2746                interrupt_requested: false,
2747                lifecycle_started: true,
2748                output_bytes: 0,
2749                usage: None,
2750                total_usage: None,
2751            },
2752        );
2753        CodexAgentRuntime {
2754            tool_policy: CodexToolPolicy::RejectAll,
2755            handshake: HandshakeState::Ready,
2756            next_id: 1,
2757            pending: BTreeMap::new(),
2758            pending_turn_started: BTreeMap::new(),
2759            models: BTreeMap::new(),
2760            threads,
2761            turns,
2762            initialized_server_version: None,
2763        }
2764    }
2765
2766    fn pending_turn_runtime() -> CodexAgentRuntime {
2767        let mut runtime = active_reasoning_runtime();
2768        runtime.pending.insert(
2769            1,
2770            PendingRequest::TurnStart {
2771                thread_id: "thr-notification".to_owned(),
2772                model: "gpt-5.6-luna".to_owned(),
2773                effort: ReasoningEffort::Low,
2774            },
2775        );
2776        runtime
2777    }
2778
2779    fn notification_first_runtime() -> CodexAgentRuntime {
2780        let mut runtime = pending_turn_runtime();
2781        runtime.turns.clear();
2782        runtime
2783    }
2784
2785    fn completable_runtime() -> CodexAgentRuntime {
2786        let mut runtime = active_reasoning_runtime();
2787        let turn = runtime
2788            .turns
2789            .get_mut(&(
2790                "thr-notification".to_owned(),
2791                "turn-notification".to_owned(),
2792            ))
2793            .expect("active turn fixture");
2794        turn.items.clear();
2795        runtime
2796    }
2797
2798    fn token_usage_notification(input_tokens: i64, output_tokens: i64) -> Value {
2799        token_usage_notification_with_total(input_tokens, output_tokens, 123456781, 123456782)
2800    }
2801
2802    fn token_usage_notification_with_total(
2803        input_tokens: i64,
2804        output_tokens: i64,
2805        total_input_tokens: i64,
2806        total_output_tokens: i64,
2807    ) -> Value {
2808        json!({
2809            "method": "thread/tokenUsage/updated",
2810            "params": {
2811                "threadId": "thr-notification",
2812                "turnId": "turn-notification",
2813                "tokenUsage": {
2814                    "last": {
2815                        "cachedInputTokens": 3,
2816                        "inputTokens": input_tokens,
2817                        "outputTokens": output_tokens,
2818                        "reasoningOutputTokens": 5,
2819                        "totalTokens": input_tokens + output_tokens
2820                    },
2821                    "total": {
2822                        "cachedInputTokens": 123456780,
2823                        "inputTokens": total_input_tokens,
2824                        "outputTokens": total_output_tokens,
2825                        "reasoningOutputTokens": 123456783,
2826                        "totalTokens": 987654321
2827                    },
2828                    "modelContextWindow": 128000
2829                }
2830            }
2831        })
2832    }
2833
2834    fn cumulative_usage(input_tokens: u64, output_tokens: u64) -> CumulativeTurnUsageCounts {
2835        CumulativeTurnUsageCounts {
2836            cached_input_tokens: 100,
2837            cache_write_input_tokens: Some(0),
2838            input_tokens,
2839            output_tokens,
2840            reasoning_output_tokens: 5,
2841            total_tokens: input_tokens + output_tokens,
2842        }
2843    }
2844
2845    fn realistic_thread_started(thread_id: &str) -> Value {
2846        json!({
2847            "method": "thread/started",
2848            "params": {
2849                "thread": {
2850                    "id": thread_id,
2851                    "preview": "private-preview-canary",
2852                    "ephemeral": true,
2853                    "modelProvider": "openai",
2854                    "createdAt": 1,
2855                    "updatedAt": 1,
2856                    "status": {"type": "idle"},
2857                    "cwd": "/private-cwd-canary",
2858                    "cliVersion": CURRENT_STANDALONE_CLI_VERSION,
2859                    "source": "appServer",
2860                    "sessionId": "private-session-canary",
2861                    "turns": [],
2862                    "agentNickname": null,
2863                    "agentRole": null,
2864                    "canAcceptDirectInput": true,
2865                    "extra": null,
2866                    "forkedFromId": null,
2867                    "gitInfo": null,
2868                    "historyMode": "legacy",
2869                    "name": null,
2870                    "parentThreadId": null,
2871                    "path": null,
2872                    "recencyAt": 1,
2873                    "section": null,
2874                    "sectionEnteredAt": null,
2875                    "threadSource": null
2876                }
2877            }
2878        })
2879    }
2880
2881    fn turn_started(thread_id: &str, turn_id: &str) -> Value {
2882        json!({
2883            "method": "turn/started",
2884            "params": {
2885                "threadId": thread_id,
2886                "turn": {
2887                    "id": turn_id,
2888                    "status": "inProgress",
2889                    "items": [],
2890                    "error": null
2891                }
2892            }
2893        })
2894    }
2895
2896    fn turn_start_response(request_id: u64, turn_id: &str) -> Value {
2897        json!({
2898            "id": request_id,
2899            "result": {
2900                "turn": {
2901                    "id": turn_id,
2902                    "status": "inProgress",
2903                    "items": [],
2904                    "error": null
2905                }
2906            }
2907        })
2908    }
2909
2910    #[test]
2911    fn correlation_mismatch_provenance_is_fixed_class_and_content_free() {
2912        assert_eq!(
2913            correlation_mismatch_response_marker(),
2914            "steadymux-owned-child: correlation-mismatch source=response class=correlation"
2915        );
2916
2917        let method = "thread/tokenUsage/updated";
2918        let marker = correlation_mismatch_notification_marker(method);
2919        let expected_sha256 = Sha256::digest(method.as_bytes())
2920            .iter()
2921            .map(|byte| format!("{byte:02x}"))
2922            .collect::<String>();
2923        assert_eq!(
2924            marker,
2925            format!(
2926                "steadymux-owned-child: correlation-mismatch source=notification handler=usage mismatch=correlation method-sha256={expected_sha256}"
2927            )
2928        );
2929        assert!(!marker.contains(method));
2930        assert!(!marker.contains("thr-notification"));
2931        assert!(!marker.contains("turn-notification"));
2932
2933        assert!(correlation_mismatch_notification_marker("item/started").contains("handler=item"));
2934        assert!(
2935            correlation_mismatch_notification_marker("mcpServer/startupStatus/updated")
2936                .contains("handler=mcp")
2937        );
2938        assert!(correlation_mismatch_notification_marker("hook/started").contains("handler=hook"));
2939        assert!(
2940            correlation_mismatch_notification_marker("remoteControl/status/changed")
2941                .contains("handler=control")
2942        );
2943    }
2944
2945    #[test]
2946    fn crossed_notification_correlation_refuses_without_mutating_turn_state() {
2947        let mut runtime = notification_first_runtime();
2948        let before = format!("{runtime:?}");
2949
2950        assert_eq!(
2951            ingest(&mut runtime, turn_started("thr-other", "turn-other")),
2952            Err(RuntimeError::CorrelationMismatch)
2953        );
2954        assert_eq!(format!("{runtime:?}"), before);
2955    }
2956
2957    #[test]
2958    fn notification_first_turn_start_reconciles_exact_response_once() {
2959        let mut runtime = notification_first_runtime();
2960        assert_eq!(
2961            ingest(
2962                &mut runtime,
2963                turn_started("thr-notification", "turn-notification")
2964            ),
2965            Ok(RuntimeEvent::TurnStarted {
2966                thread_id: "thr-notification".to_owned(),
2967                turn_id: "turn-notification".to_owned()
2968            })
2969        );
2970        assert_eq!(runtime.pending_turn_started.len(), 1);
2971        assert!(
2972            runtime
2973                .turns
2974                .get(&(
2975                    "thr-notification".to_owned(),
2976                    "turn-notification".to_owned()
2977                ))
2978                .is_some_and(|turn| {
2979                    turn.model == "gpt-5.6-luna"
2980                        && turn.effort == ReasoningEffort::Low
2981                        && turn.lifecycle_started
2982                })
2983        );
2984
2985        assert_eq!(
2986            ingest(&mut runtime, turn_start_response(1, "turn-notification")),
2987            Ok(RuntimeEvent::TurnAccepted {
2988                thread_id: "thr-notification".to_owned(),
2989                turn_id: "turn-notification".to_owned()
2990            })
2991        );
2992        assert!(runtime.pending_turn_started.is_empty());
2993        assert!(
2994            runtime
2995                .turns
2996                .get(&(
2997                    "thr-notification".to_owned(),
2998                    "turn-notification".to_owned()
2999                ))
3000                .is_some_and(|turn| turn.lifecycle_started)
3001        );
3002        assert_eq!(
3003            ingest(
3004                &mut runtime,
3005                turn_started("thr-notification", "turn-notification")
3006            ),
3007            Err(RuntimeError::CorrelationMismatch)
3008        );
3009    }
3010
3011    #[test]
3012    fn response_first_turn_start_remains_valid() {
3013        let mut runtime = notification_first_runtime();
3014        assert!(matches!(
3015            ingest(&mut runtime, turn_start_response(1, "turn-notification")),
3016            Ok(RuntimeEvent::TurnAccepted { .. })
3017        ));
3018        assert!(runtime.pending_turn_started.is_empty());
3019        assert_eq!(
3020            ingest(
3021                &mut runtime,
3022                turn_started("thr-notification", "turn-notification")
3023            ),
3024            Ok(RuntimeEvent::TurnStarted {
3025                thread_id: "thr-notification".to_owned(),
3026                turn_id: "turn-notification".to_owned()
3027            })
3028        );
3029    }
3030
3031    #[test]
3032    fn response_first_usage_before_turn_started_completes_exactly_once() {
3033        let mut runtime = notification_first_runtime();
3034        assert!(matches!(
3035            ingest(&mut runtime, turn_start_response(1, "turn-notification")),
3036            Ok(RuntimeEvent::TurnAccepted { .. })
3037        ));
3038        assert_eq!(
3039            ingest(&mut runtime, token_usage_notification(12, 34)),
3040            Ok(RuntimeEvent::NonSemanticNotificationIgnored),
3041            "the accepted response creates the only record usage may update"
3042        );
3043        assert_eq!(
3044            ingest(
3045                &mut runtime,
3046                turn_started("thr-notification", "turn-notification")
3047            ),
3048            Ok(RuntimeEvent::TurnStarted {
3049                thread_id: "thr-notification".to_owned(),
3050                turn_id: "turn-notification".to_owned(),
3051            })
3052        );
3053        let terminal = ingest(
3054            &mut runtime,
3055            json!({
3056                "method": "turn/completed",
3057                "params": {
3058                    "threadId": "thr-notification",
3059                    "turn": {
3060                        "id": "turn-notification",
3061                        "status": "completed",
3062                        "items": [],
3063                        "error": null
3064                    }
3065                }
3066            }),
3067        )
3068        .expect("response-first usage becomes terminal only after turn/started");
3069        assert!(matches!(
3070            terminal,
3071            RuntimeEvent::TurnCompleted(TurnCompletionReceipt {
3072                input_tokens: Some(12),
3073                output_tokens: Some(34),
3074                ..
3075            })
3076        ));
3077    }
3078
3079    #[test]
3080    fn response_first_usage_accepts_monotonic_totals_but_refuses_mismatched_turns() {
3081        let mut before_accepted = notification_first_runtime();
3082        assert_eq!(
3083            ingest(&mut before_accepted, token_usage_notification(12, 34)),
3084            Err(RuntimeError::ProtocolFailure),
3085            "a notification cannot create a turn"
3086        );
3087
3088        let mut runtime = notification_first_runtime();
3089        ingest(&mut runtime, turn_start_response(1, "turn-notification"))
3090            .expect("accepted turn response");
3091        assert_eq!(
3092            ingest(&mut runtime, token_usage_notification(12, 34)),
3093            Ok(RuntimeEvent::NonSemanticNotificationIgnored)
3094        );
3095        assert_eq!(
3096            ingest(&mut runtime, token_usage_notification(12, 34)),
3097            Ok(RuntimeEvent::NonSemanticNotificationIgnored),
3098            "an identical authoritative duplicate is idempotent"
3099        );
3100        assert_eq!(
3101            ingest(
3102                &mut runtime,
3103                token_usage_notification_with_total(13, 34, 123456782, 123456783),
3104            ),
3105            Ok(RuntimeEvent::NonSemanticNotificationIgnored),
3106            "a monotonic cumulative projection accepts the latest per-turn usage even before lifecycle start"
3107        );
3108
3109        let mut mismatched = notification_first_runtime();
3110        ingest(&mut mismatched, turn_start_response(1, "turn-notification"))
3111            .expect("accepted turn response");
3112        let mut wrong_ids = token_usage_notification(12, 34);
3113        wrong_ids["params"]["turnId"] = json!("turn-other");
3114        assert_eq!(
3115            ingest(&mut mismatched, wrong_ids),
3116            Err(RuntimeError::ProtocolFailure),
3117            "usage must bind to the accepted response turn"
3118        );
3119        assert_eq!(
3120            ingest(&mut mismatched, token_usage_notification(12, 34)),
3121            Ok(RuntimeEvent::NonSemanticNotificationIgnored),
3122            "a mismatched update cannot consume the accepted turn's usage slot"
3123        );
3124    }
3125
3126    #[test]
3127    fn duplicate_notification_first_turn_start_is_rejected() {
3128        let mut runtime = notification_first_runtime();
3129        ingest(
3130            &mut runtime,
3131            turn_started("thr-notification", "turn-notification"),
3132        )
3133        .expect("first exactly correlated notification");
3134        assert_eq!(
3135            ingest(
3136                &mut runtime,
3137                turn_started("thr-notification", "turn-notification")
3138            ),
3139            Err(RuntimeError::CorrelationMismatch)
3140        );
3141        assert_eq!(runtime.pending_turn_started.len(), 1);
3142    }
3143
3144    #[test]
3145    fn notification_first_requires_exactly_one_pending_turn_start() {
3146        let mut runtime = notification_first_runtime();
3147        runtime.pending.insert(
3148            2,
3149            PendingRequest::TurnStart {
3150                thread_id: "thr-notification".to_owned(),
3151                model: "gpt-5.6-luna".to_owned(),
3152                effort: ReasoningEffort::Low,
3153            },
3154        );
3155        assert_eq!(
3156            ingest(
3157                &mut runtime,
3158                turn_started("thr-notification", "turn-notification")
3159            ),
3160            Err(RuntimeError::CorrelationMismatch)
3161        );
3162        assert!(runtime.pending_turn_started.is_empty());
3163    }
3164
3165    #[test]
3166    fn notification_first_rejects_unmatched_thread() {
3167        let mut runtime = notification_first_runtime();
3168        assert_eq!(
3169            ingest(
3170                &mut runtime,
3171                turn_started("thr-unmatched", "turn-notification")
3172            ),
3173            Err(RuntimeError::CorrelationMismatch)
3174        );
3175        assert!(runtime.pending_turn_started.is_empty());
3176    }
3177
3178    #[test]
3179    fn notification_first_mismatched_response_consumes_marker_and_fails_closed() {
3180        let mut runtime = notification_first_runtime();
3181        ingest(
3182            &mut runtime,
3183            turn_started("thr-notification", "turn-notification"),
3184        )
3185        .expect("first exactly correlated notification");
3186        assert_eq!(
3187            ingest(&mut runtime, turn_start_response(1, "turn-mismatch")),
3188            Err(RuntimeError::CorrelationMismatch)
3189        );
3190        assert!(runtime.pending_turn_started.is_empty());
3191        assert!(runtime.turns.is_empty());
3192    }
3193
3194    #[test]
3195    fn notification_first_turn_start_capacity_is_bounded() {
3196        let mut runtime = pending_turn_runtime();
3197        let turn = runtime
3198            .turns
3199            .values()
3200            .next()
3201            .expect("turn state fixture")
3202            .clone();
3203        runtime.turns.clear();
3204        for index in 0..MAX_TURNS {
3205            runtime.turns.insert(
3206                (
3207                    format!("thr-capacity-{index}"),
3208                    format!("turn-capacity-{index}"),
3209                ),
3210                turn.clone(),
3211            );
3212        }
3213        assert_eq!(
3214            ingest(
3215                &mut runtime,
3216                turn_started("thr-notification", "turn-notification")
3217            ),
3218            Err(RuntimeError::PendingCapacityExceeded)
3219        );
3220        assert!(runtime.pending_turn_started.is_empty());
3221    }
3222
3223    #[test]
3224    fn exact_disabled_remote_control_status_is_content_free() {
3225        let mut runtime = CodexAgentRuntime::new();
3226        let event = ingest(
3227            &mut runtime,
3228            json!({
3229                "method": "remoteControl/status/changed",
3230                "params": {
3231                    "status": "disabled",
3232                    "serverName": "private-server-canary",
3233                    "installationId": "private-installation-canary",
3234                    "environmentId": null
3235                },
3236                "emittedAtMs": 1
3237            }),
3238        )
3239        .expect("exact reviewed control notification");
3240
3241        assert_eq!(event, RuntimeEvent::NonSemanticNotificationIgnored);
3242        let debug = format!("{event:?}");
3243        assert!(!debug.contains("private-server-canary"));
3244        assert!(!debug.contains("private-installation-canary"));
3245    }
3246
3247    #[test]
3248    fn json_rpc_rejection_discards_message_and_data_content() {
3249        let mut runtime = pending_turn_runtime();
3250        let error = ingest(
3251            &mut runtime,
3252            json!({
3253                "jsonrpc": "2.0",
3254                "id": 1,
3255                "error": {
3256                    "code": -32603,
3257                    "message": "private-json-rpc-message-canary",
3258                    "data": {"content": "private-json-rpc-data-canary"}
3259                }
3260            }),
3261        )
3262        .expect_err("JSON-RPC error response must reject the request");
3263
3264        assert_eq!(error, RuntimeError::JsonRpcRejected);
3265        let debug = format!("{error:?} {error} {runtime:?}");
3266        assert!(!debug.contains("private-json-rpc-message-canary"));
3267        assert!(!debug.contains("private-json-rpc-data-canary"));
3268    }
3269
3270    #[test]
3271    fn malformed_json_rpc_error_is_a_protocol_failure() {
3272        let mut runtime = pending_turn_runtime();
3273
3274        assert_eq!(
3275            ingest(
3276                &mut runtime,
3277                json!({"id": 1, "error": {"message": "private-malformed-canary"}}),
3278            ),
3279            Err(RuntimeError::ProtocolFailure)
3280        );
3281        assert!(!format!("{runtime:?}").contains("private-malformed-canary"));
3282    }
3283
3284    #[test]
3285    fn completed_turn_exposes_only_authoritative_last_turn_usage() {
3286        let mut runtime = completable_runtime();
3287        assert_eq!(
3288            ingest(&mut runtime, token_usage_notification(12, 34)),
3289            Ok(RuntimeEvent::NonSemanticNotificationIgnored)
3290        );
3291        let terminal = ingest(
3292            &mut runtime,
3293            json!({
3294                "method": "turn/completed",
3295                "params": {
3296                    "threadId": "thr-notification",
3297                    "turn": {
3298                        "id": "turn-notification",
3299                        "status": "completed",
3300                        "items": [],
3301                        "error": null
3302                    }
3303                }
3304            }),
3305        )
3306        .expect("completed turn with one usage update");
3307        let RuntimeEvent::TurnCompleted(receipt) = terminal else {
3308            panic!("expected terminal receipt");
3309        };
3310        assert_eq!(receipt.input_tokens, Some(12));
3311        assert_eq!(receipt.output_tokens, Some(34));
3312        let debug = format!("{receipt:?} {runtime:?}");
3313        assert!(!debug.contains("987654321"));
3314        assert!(!debug.contains("123456781"));
3315    }
3316
3317    #[test]
3318    fn progressive_cumulative_usage_projects_the_latest_per_turn_snapshot_at_completion() {
3319        let mut runtime = completable_runtime();
3320        ingest(
3321            &mut runtime,
3322            token_usage_notification_with_total(12, 34, 1_000, 2_000),
3323        )
3324        .expect("initial complete cumulative projection");
3325        ingest(
3326            &mut runtime,
3327            token_usage_notification_with_total(7, 41, 1_010, 2_010),
3328        )
3329        .expect("later cumulative projection may replace a varying last snapshot");
3330        ingest(
3331            &mut runtime,
3332            token_usage_notification_with_total(7, 41, 1_010, 2_010),
3333        )
3334        .expect("an identical cumulative projection is idempotent");
3335
3336        let terminal = ingest(
3337            &mut runtime,
3338            json!({
3339                "method": "turn/completed",
3340                "params": {
3341                    "threadId": "thr-notification",
3342                    "turn": {
3343                        "id": "turn-notification",
3344                        "status": "completed",
3345                        "items": [],
3346                        "error": null
3347                    }
3348                }
3349            }),
3350        )
3351        .expect("a turn with lifecycle and cumulative usage completes");
3352        assert!(matches!(
3353            terminal,
3354            RuntimeEvent::TurnCompleted(TurnCompletionReceipt {
3355                input_tokens: Some(7),
3356                output_tokens: Some(41),
3357                ..
3358            })
3359        ));
3360        let debug = format!("{runtime:?}");
3361        for forbidden in ["1000", "1010", "2000", "2010"] {
3362            assert!(
3363                !debug.contains(forbidden),
3364                "cumulative values must remain private"
3365            );
3366        }
3367    }
3368
3369    #[test]
3370    fn nonmonotonic_cumulative_usage_refuses_without_mutating_the_latest_snapshot() {
3371        let mut runtime = completable_runtime();
3372        ingest(
3373            &mut runtime,
3374            token_usage_notification_with_total(12, 34, 1_000, 2_000),
3375        )
3376        .expect("initial complete cumulative projection");
3377        let before = runtime.turns.values().next().expect("turn fixture").clone();
3378        assert_eq!(
3379            ingest(
3380                &mut runtime,
3381                token_usage_notification_with_total(7, 41, 999, 2_010),
3382            ),
3383            Err(RuntimeError::ProtocolFailure),
3384            "a decrease in a validated cumulative projection remains fail closed"
3385        );
3386        let after = runtime.turns.values().next().expect("turn fixture");
3387        assert_eq!(after.usage, before.usage);
3388        assert_eq!(after.total_usage, before.total_usage);
3389    }
3390
3391    #[test]
3392    fn completed_turn_requires_authoritative_usage_but_ignores_identical_duplicate() {
3393        let mut missing = completable_runtime();
3394        assert_eq!(
3395            ingest(
3396                &mut missing,
3397                json!({"method":"turn/completed","params":{"threadId":"thr-notification","turn":{"id":"turn-notification","status":"completed","items":[],"error":null}}}),
3398            ),
3399            Err(RuntimeError::ProtocolFailure)
3400        );
3401
3402        let mut duplicate = completable_runtime();
3403        ingest(&mut duplicate, token_usage_notification(12, 34)).expect("first usage update");
3404        assert_eq!(
3405            ingest(&mut duplicate, token_usage_notification(12, 34)),
3406            Ok(RuntimeEvent::NonSemanticNotificationIgnored),
3407            "the app-server may repeat an unchanged authoritative update"
3408        );
3409    }
3410
3411    #[test]
3412    fn interrupted_or_failed_turn_usage_is_optional_but_never_invented() {
3413        let mut failed = completable_runtime();
3414        let failed = ingest(
3415            &mut failed,
3416            json!({"method":"turn/completed","params":{"threadId":"thr-notification","turn":{"id":"turn-notification","status":"failed","items":[],"error":{"codexErrorInfo":"other"}}}}),
3417        )
3418        .expect("failed turn without usage remains representable");
3419        assert!(matches!(
3420            failed,
3421            RuntimeEvent::TurnCompleted(TurnCompletionReceipt {
3422                input_tokens: None,
3423                output_tokens: None,
3424                ..
3425            })
3426        ));
3427
3428        let mut interrupted = completable_runtime();
3429        interrupted
3430            .turns
3431            .get_mut(&(
3432                "thr-notification".to_owned(),
3433                "turn-notification".to_owned(),
3434            ))
3435            .expect("interrupt turn fixture")
3436            .interrupt_requested = true;
3437        ingest(&mut interrupted, token_usage_notification(7, 9))
3438            .expect("single interrupted usage update");
3439        let interrupted = ingest(
3440            &mut interrupted,
3441            json!({"method":"turn/completed","params":{"threadId":"thr-notification","turn":{"id":"turn-notification","status":"interrupted","items":[],"error":null}}}),
3442        )
3443        .expect("interrupted turn with usage remains representable");
3444        assert!(matches!(
3445            interrupted,
3446            RuntimeEvent::TurnCompleted(TurnCompletionReceipt {
3447                input_tokens: Some(7),
3448                output_tokens: Some(9),
3449                ..
3450            })
3451        ));
3452    }
3453
3454    #[test]
3455    #[cfg(unix)]
3456    fn exact_ephemeral_empty_thread_started_is_content_free() {
3457        let mut runtime = active_reasoning_runtime();
3458        let event = ingest(&mut runtime, realistic_thread_started("thr-notification"))
3459            .expect("reviewed ephemeral thread notification");
3460
3461        assert_eq!(event, RuntimeEvent::NonSemanticNotificationIgnored);
3462        let debug = format!("{event:?} {runtime:?}");
3463        for canary in [
3464            "private-preview-canary",
3465            "private-cwd-canary",
3466            "private-session-canary",
3467            "thr-notification",
3468        ] {
3469            assert!(!debug.contains(canary));
3470        }
3471    }
3472
3473    #[test]
3474    #[cfg(unix)]
3475    fn thread_started_requires_registered_correlation() {
3476        let mut runtime = active_reasoning_runtime();
3477        assert_eq!(
3478            ingest(&mut runtime, realistic_thread_started("thr-unregistered")),
3479            Err(RuntimeError::CorrelationMismatch)
3480        );
3481    }
3482
3483    #[test]
3484    fn malformed_extended_or_side_effecting_thread_started_fails_closed() {
3485        let mut malformed = Vec::new();
3486
3487        let mut missing = realistic_thread_started("thr-notification");
3488        missing["params"]["thread"]
3489            .as_object_mut()
3490            .expect("thread fixture object")
3491            .remove("ephemeral");
3492        malformed.push(missing);
3493
3494        let mut extended = realistic_thread_started("thr-notification");
3495        extended["params"]["thread"]["future"] = json!(true);
3496        malformed.push(extended);
3497
3498        let mut persistent = realistic_thread_started("thr-notification");
3499        persistent["params"]["thread"]["ephemeral"] = json!(false);
3500        malformed.push(persistent);
3501
3502        let mut materialized = realistic_thread_started("thr-notification");
3503        materialized["params"]["thread"]["path"] = json!("/private/thread.jsonl");
3504        malformed.push(materialized);
3505
3506        let mut with_turn = realistic_thread_started("thr-notification");
3507        with_turn["params"]["thread"]["turns"] = json!([{"id":"turn-existing"}]);
3508        malformed.push(with_turn);
3509
3510        let mut subagent = realistic_thread_started("thr-notification");
3511        subagent["params"]["thread"]["source"] = json!({"subAgent":"review"});
3512        malformed.push(subagent);
3513
3514        let mut git = realistic_thread_started("thr-notification");
3515        git["params"]["thread"]["gitInfo"] = json!({"branch":"private-branch"});
3516        malformed.push(git);
3517
3518        let mut extra = realistic_thread_started("thr-notification");
3519        extra["params"]["thread"]["extra"] = json!({"private":"payload"});
3520        malformed.push(extra);
3521
3522        let mut section = realistic_thread_started("thr-notification");
3523        section["params"]["thread"]["section"] = json!({"id":"private-section"});
3524        malformed.push(section);
3525
3526        let mut section_entered = realistic_thread_started("thr-notification");
3527        section_entered["params"]["thread"]["sectionEnteredAt"] = json!(1);
3528        malformed.push(section_entered);
3529
3530        let mut direct_input = realistic_thread_started("thr-notification");
3531        direct_input["params"]["thread"]["canAcceptDirectInput"] = json!(false);
3532        malformed.push(direct_input);
3533
3534        let mut history = realistic_thread_started("thr-notification");
3535        history["params"]["thread"]["historyMode"] = json!("future");
3536        malformed.push(history);
3537
3538        let mut oversized = realistic_thread_started("thr-notification");
3539        oversized["params"]["thread"]["preview"] = json!("x".repeat(MAX_TEXT_BYTES + 1));
3540        malformed.push(oversized);
3541
3542        let mut extended_params = realistic_thread_started("thr-notification");
3543        extended_params["params"]["future"] = json!(true);
3544        malformed.push(extended_params);
3545
3546        for (index, fixture) in malformed.into_iter().enumerate() {
3547            let mut runtime = active_reasoning_runtime();
3548            assert_eq!(
3549                ingest(&mut runtime, fixture),
3550                Err(RuntimeError::ProtocolFailure),
3551                "malformed notification fixture {index}"
3552            );
3553        }
3554    }
3555
3556    #[test]
3557    fn exact_no_tools_non_semantic_notifications_are_content_free() {
3558        let fixtures = [
3559            json!({"method":"thread/status/changed","params":{"threadId":"thr-notification","status":{"type":"active","activeFlags":[]}}}),
3560            json!({"method":"thread/status/changed","params":{"threadId":"thr-notification","status":{"type":"active","activeFlags":["waitingOnUserInput"]}}}),
3561            json!({"method":"thread/status/changed","params":{"threadId":"thr-notification","status":{"type":"idle"}}}),
3562            json!({"method":"thread/tokenUsage/updated","params":{"threadId":"thr-notification","turnId":"turn-notification","tokenUsage":{"last":{"cachedInputTokens":1,"inputTokens":2,"outputTokens":3,"reasoningOutputTokens":4,"totalTokens":10},"total":{"cacheWriteInputTokens":0,"cachedInputTokens":11,"inputTokens":12,"outputTokens":13,"reasoningOutputTokens":14,"totalTokens":50},"modelContextWindow":128000}}}),
3563            json!({"method":"item/reasoning/summaryPartAdded","params":{"threadId":"thr-notification","turnId":"turn-notification","itemId":"reasoning-notification","summaryIndex":0}}),
3564            json!({"method":"item/reasoning/summaryTextDelta","params":{"threadId":"thr-notification","turnId":"turn-notification","itemId":"reasoning-notification","summaryIndex":0,"delta":"private-summary-canary"}}),
3565            json!({"method":"item/reasoning/textDelta","params":{"threadId":"thr-notification","turnId":"turn-notification","itemId":"reasoning-notification","contentIndex":0,"delta":"private-reasoning-canary"}}),
3566        ];
3567        let mut runtime = active_reasoning_runtime();
3568        for fixture in fixtures {
3569            let event = ingest(&mut runtime, fixture).expect("reviewed non-semantic notification");
3570            assert_eq!(event, RuntimeEvent::NonSemanticNotificationIgnored);
3571            let debug = format!("{event:?}");
3572            assert!(!debug.contains("private-summary-canary"));
3573            assert!(!debug.contains("private-reasoning-canary"));
3574            assert!(!debug.contains("thr-notification"));
3575            assert!(!debug.contains("turn-notification"));
3576            assert!(!debug.contains("reasoning-notification"));
3577        }
3578    }
3579
3580    #[test]
3581    fn thread_status_active_flags_match_the_installed_protocol_enum() {
3582        let mut runtime = active_reasoning_runtime();
3583        for invalid in [
3584            json!({"method":"thread/status/changed","params":{"threadId":"thr-notification","status":{"type":"active","activeFlags":["unknown"]}}}),
3585            json!({"method":"thread/status/changed","params":{"threadId":"thr-notification","status":{"type":"active","activeFlags":["waitingOnApproval","waitingOnUserInput","waitingOnApproval"]}}}),
3586            json!({"method":"thread/status/changed","params":{"threadId":"thr-notification","status":{"type":"active","activeFlags":[1]}}}),
3587        ] {
3588            assert!(matches!(
3589                ingest(&mut runtime, invalid),
3590                Err(RuntimeError::ProtocolFailure)
3591            ));
3592        }
3593    }
3594
3595    #[test]
3596    fn installed_mcp_startup_notifications_are_content_free() {
3597        let fixtures = [
3598            json!({
3599                "method": "mcpServer/startupStatus/updated",
3600                "params": {
3601                    "threadId": "thr-notification",
3602                    "name": "openai-api-key-local-confirmation",
3603                    "status": "starting",
3604                    "error": null,
3605                    "failureReason": null
3606                },
3607                "emittedAtMs": 1_786_054_396_424_u64
3608            }),
3609            json!({
3610                "method": "mcpServer/startupStatus/updated",
3611                "params": {
3612                    "threadId": "thr-notification",
3613                    "name": "context7",
3614                    "status": "ready",
3615                    "error": null,
3616                    "failureReason": null
3617                },
3618                "emittedAtMs": 1_786_054_397_661_u64
3619            }),
3620            json!({
3621                "method": "mcpServer/startupStatus/updated",
3622                "params": {
3623                    "name": "context7",
3624                    "status": "starting"
3625                },
3626                "emittedAtMs": 1_786_054_397_662_u64
3627            }),
3628            json!({
3629                "method": "mcpServer/startupStatus/updated",
3630                "params": {
3631                    "threadId": null,
3632                    "name": "context7",
3633                    "status": "cancelled",
3634                    "error": null,
3635                    "failureReason": null
3636                },
3637                "emittedAtMs": 1_786_054_397_663_u64
3638            }),
3639            json!({
3640                "method": "mcpServer/startupStatus/updated",
3641                "params": {
3642                    "threadId": "thr-notification",
3643                    "name": "context7",
3644                    "status": "failed",
3645                    "error": null,
3646                    "failureReason": "reauthenticationRequired"
3647                },
3648                "emittedAtMs": 1_786_054_397_664_u64
3649            }),
3650            json!({
3651                "method": "mcpServer/startupStatus/updated",
3652                "params": {
3653                    "threadId": "thr-notification",
3654                    "name": "context7",
3655                    "status": "failed",
3656                    "error": "MCP startup failed",
3657                    "failureReason": null
3658                },
3659                "emittedAtMs": 1_786_054_397_665_u64
3660            }),
3661        ];
3662        let mut runtime = active_reasoning_runtime();
3663
3664        for fixture in fixtures {
3665            let event = ingest(&mut runtime, fixture).expect("live-shaped MCP startup status");
3666            assert_eq!(event, RuntimeEvent::NonSemanticNotificationIgnored);
3667            let debug = format!("{event:?} {runtime:?}");
3668            assert!(!debug.contains("openai-api-key-local-confirmation"));
3669            assert!(!debug.contains("context7"));
3670            assert!(!debug.contains("thr-notification"));
3671            assert!(!debug.contains("MCP startup failed"));
3672        }
3673    }
3674
3675    #[test]
3676    fn malformed_extended_or_diagnostic_mcp_startup_notifications_fail_closed() {
3677        let malformed = [
3678            json!({"threadId":"thr-notification","name":"context7","status":"ready","error":"","failureReason":null}),
3679            json!({"threadId":"thr-notification","name":"context7","status":"ready","error":"startup\nfailed","failureReason":null}),
3680            json!({"threadId":"thr-notification","name":"context7","status":"ready","error":1,"failureReason":null}),
3681            json!({"threadId":"thr-notification","name":"context7","status":"failed","error":null,"failureReason":"future"}),
3682            json!({"threadId":"thr-notification","name":"","status":"ready","error":null,"failureReason":null}),
3683            json!({"threadId":"thr-notification","name":"context7"}),
3684            json!({"name":"context7","status":"future"}),
3685            json!({"threadId":1,"name":"context7","status":"ready"}),
3686            json!({"threadId":"thr-notification","name":"context7","status":"ready","error":null,"failureReason":null,"future":true}),
3687        ];
3688
3689        for params in malformed {
3690            let mut runtime = active_reasoning_runtime();
3691            assert!(
3692                ingest(
3693                    &mut runtime,
3694                    json!({"method":"mcpServer/startupStatus/updated","params":params,"emittedAtMs":1}),
3695                )
3696                .is_err()
3697            );
3698        }
3699
3700        let mut unregistered = active_reasoning_runtime();
3701        assert_eq!(
3702            ingest(
3703                &mut unregistered,
3704                json!({"method":"mcpServer/startupStatus/updated","params":{"threadId":"thr-unregistered","name":"context7","status":"ready","error":null,"failureReason":null},"emittedAtMs":1}),
3705            ),
3706            Err(RuntimeError::CorrelationMismatch)
3707        );
3708
3709        let secret = "sk-private-mcp-startup-canary";
3710        let mut sensitive = active_reasoning_runtime();
3711        let error = ingest(
3712            &mut sensitive,
3713            json!({"method":"mcpServer/startupStatus/updated","params":{"threadId":"thr-notification","name":"context7","status":"ready","error":secret,"failureReason":null},"emittedAtMs":1}),
3714        )
3715        .expect_err("secret-shaped diagnostic must fail closed");
3716        assert_eq!(error, RuntimeError::SensitiveInputRejected);
3717        assert!(!format!("{error:?} {error} {sensitive:?}").contains(secret));
3718    }
3719
3720    #[test]
3721    fn live_user_message_lifecycle_is_correlated_and_not_retained() {
3722        let item = json!({
3723            "type": "userMessage",
3724            "id": "msg-user-live",
3725            "clientId": "steadymux-live-canary",
3726            "content": [{
3727                "type": "text",
3728                "text": "Return the single token STEADYMUX_CANARY.",
3729                "text_elements": []
3730            }]
3731        });
3732        let mut runtime = active_reasoning_runtime();
3733        let item_count = runtime
3734            .turns
3735            .values()
3736            .next()
3737            .expect("active turn")
3738            .items
3739            .len();
3740
3741        for (method, timestamp_key) in [
3742            ("item/started", "startedAtMs"),
3743            ("item/completed", "completedAtMs"),
3744        ] {
3745            let event = ingest(
3746                &mut runtime,
3747                json!({
3748                    "method": method,
3749                    "params": {
3750                        "item": item.clone(),
3751                        "threadId": "thr-notification",
3752                        "turnId": "turn-notification",
3753                        timestamp_key: 1_786_054_552_017_u64
3754                    },
3755                    "emittedAtMs": 1_786_054_552_018_u64
3756                }),
3757            )
3758            .expect("live-shaped user message lifecycle");
3759            assert_eq!(event, RuntimeEvent::NonSemanticNotificationIgnored);
3760        }
3761        assert_eq!(
3762            runtime
3763                .turns
3764                .values()
3765                .next()
3766                .expect("active turn")
3767                .items
3768                .len(),
3769            item_count
3770        );
3771        let debug = format!("{runtime:?}");
3772        assert!(!debug.contains("STEADYMUX_CANARY"));
3773        assert!(!debug.contains("steadymux-live-canary"));
3774        assert!(!debug.contains("msg-user-live"));
3775    }
3776
3777    #[test]
3778    #[cfg(unix)]
3779    fn live_warning_and_rate_notifications_are_content_free_while_hooks_refuse() {
3780        let fixtures = [
3781            json!({"method":"warning","params":{"threadId":"thr-notification","message":"Skill descriptions were shortened to fit the bounded skills context budget."},"emittedAtMs":1}),
3782            json!({"method":"account/rateLimits/updated","params":{"rateLimits":{"limitId":"codex","limitName":null,"primary":{"usedPercent":95,"windowDurationMins":10080,"resetsAt":1_786_323_626_u64},"secondary":null,"credits":{"hasCredits":false,"unlimited":false,"balance":"0"},"individualLimit":null,"spendControlReached":null,"planType":"pro","rateLimitReachedType":null}},"emittedAtMs":1}),
3783        ];
3784        let mut runtime = active_reasoning_runtime();
3785
3786        for fixture in fixtures {
3787            assert_eq!(
3788                ingest(&mut runtime, fixture),
3789                Ok(RuntimeEvent::NonSemanticNotificationIgnored)
3790            );
3791        }
3792        for method in ["hook/started", "hook/completed"] {
3793            assert_eq!(
3794                ingest(
3795                    &mut runtime,
3796                    json!({"method":method,"params":{"private":"hook payload"},"emittedAtMs":1}),
3797                ),
3798                Err(RuntimeError::ProtocolFailure)
3799            );
3800        }
3801        let debug = format!("{runtime:?}");
3802        assert!(!debug.contains("hook payload"));
3803        assert!(!debug.contains("usedPercent"));
3804    }
3805
3806    #[test]
3807    #[cfg(unix)]
3808    fn exhausted_account_rate_notification_is_not_a_protocol_failure() {
3809        // An exhausted subscription emits rateLimitReachedType and
3810        // spendControlReached populated. This must be accepted as a
3811        // content-free non-semantic notification so the cascade can fall
3812        // through to the next provider instead of misclassifying it as an
3813        // ambiguous post-write protocol failure.
3814        let exhausted = json!({"method":"account/rateLimits/updated","params":{"rateLimits":{"limitId":"codex","limitName":null,"primary":{"usedPercent":100,"windowDurationMins":10080,"resetsAt":1_786_323_626_u64},"secondary":null,"credits":{"hasCredits":false,"unlimited":false,"balance":"0"},"individualLimit":null,"spendControlReached":true,"planType":"pro","rateLimitReachedType":"spend_control"}},"emittedAtMs":1});
3815        let mut runtime = active_reasoning_runtime();
3816        assert_eq!(
3817            ingest(&mut runtime, exhausted),
3818            Ok(RuntimeEvent::NonSemanticNotificationIgnored)
3819        );
3820        let debug = format!("{runtime:?}");
3821        assert!(!debug.contains("spend_control"));
3822        assert!(!debug.contains("usedPercent"));
3823    }
3824
3825    #[test]
3826    fn malformed_or_extended_no_tools_notifications_fail_closed() {
3827        let malformed = [
3828            json!({"method":"thread/status/changed","params":{"threadId":"thr-notification","status":{"type":"active","activeFlags":["unknown"]}}}),
3829            json!({"method":"thread/status/changed","params":{"threadId":"thr-notification","status":{"type":"idle"},"future":true}}),
3830            json!({"method":"thread/tokenUsage/updated","params":{"threadId":"thr-notification","turnId":"turn-notification","tokenUsage":{"last":{},"total":{}}}}),
3831            json!({"method":"thread/tokenUsage/updated","params":{"threadId":"thr-notification","turnId":"turn-notification","tokenUsage":{"last":{"cachedInputTokens":1,"inputTokens":2,"outputTokens":3,"reasoningOutputTokens":4,"totalTokens":10},"total":{"cachedInputTokens":1,"inputTokens":2,"outputTokens":3,"reasoningOutputTokens":4,"totalTokens":10}},"future":true}}),
3832            json!({"method":"thread/tokenUsage/updated","params":{"threadId":"thr-notification","turnId":"turn-notification","tokenUsage":{"last":{"cachedInputTokens":1,"inputTokens":2,"outputTokens":3,"reasoningOutputTokens":4,"totalTokens":10},"total":{"cachedInputTokens":1,"inputTokens":2,"outputTokens":3,"reasoningOutputTokens":4,"totalTokens":10},"future":true}}}),
3833            json!({"method":"thread/tokenUsage/updated","params":{"threadId":"thr-notification","turnId":"turn-notification","tokenUsage":{"last":{"cachedInputTokens":1,"inputTokens":2,"outputTokens":3,"reasoningOutputTokens":4,"totalTokens":10,"future":0},"total":{"cachedInputTokens":1,"inputTokens":2,"outputTokens":3,"reasoningOutputTokens":4,"totalTokens":10}}}}),
3834            json!({"method":"item/reasoning/summaryPartAdded","params":{"threadId":"thr-notification","turnId":"turn-notification","itemId":"reasoning-notification","summaryIndex":-1}}),
3835            json!({"method":"item/reasoning/summaryPartAdded","params":{"threadId":"thr-notification","turnId":"turn-notification","itemId":"reasoning-notification","summaryIndex":0,"future":true}}),
3836            json!({"method":"item/reasoning/summaryTextDelta","params":{"threadId":"thr-notification","turnId":"turn-notification","itemId":"reasoning-notification","summaryIndex":0}}),
3837            json!({"method":"item/reasoning/summaryTextDelta","params":{"threadId":"thr-notification","turnId":"turn-notification","itemId":"reasoning-notification","summaryIndex":0,"delta":"value","future":true}}),
3838            json!({"method":"item/reasoning/textDelta","params":{"threadId":"thr-notification","turnId":"turn-notification","itemId":"reasoning-notification","contentIndex":-1,"delta":"value"}}),
3839            json!({"method":"item/reasoning/textDelta","params":{"threadId":"thr-notification","turnId":"turn-notification","itemId":"reasoning-notification","contentIndex":0,"delta":"value","future":true}}),
3840        ];
3841        for (index, fixture) in malformed.into_iter().enumerate() {
3842            let mut runtime = active_reasoning_runtime();
3843            assert_eq!(
3844                ingest(&mut runtime, fixture),
3845                Err(RuntimeError::ProtocolFailure),
3846                "malformed no-tools notification fixture {index}"
3847            );
3848        }
3849    }
3850
3851    #[test]
3852    fn token_usage_breakdown_validation_accepts_exact_counts_and_refuses_shape_keys_and_values() {
3853        let exact = json!({
3854            "cachedInputTokens": 1,
3855            "inputTokens": 2,
3856            "outputTokens": 3,
3857            "reasoningOutputTokens": 4,
3858            "totalTokens": 10,
3859        });
3860        assert_eq!(
3861            valid_token_breakdown(Some(&exact)),
3862            TokenBreakdownValidation::Valid
3863        );
3864        assert_eq!(
3865            valid_token_breakdown(Some(&json!(null))),
3866            TokenBreakdownValidation::Shape
3867        );
3868        assert_eq!(
3869            valid_token_breakdown(Some(&json!({"inputTokens": 2, "future": 0}))),
3870            TokenBreakdownValidation::Keys
3871        );
3872        assert_eq!(
3873            valid_token_breakdown(Some(&json!({
3874                "cachedInputTokens": 1,
3875                "inputTokens": -2,
3876                "outputTokens": 3,
3877                "reasoningOutputTokens": 4,
3878                "totalTokens": 10,
3879            }))),
3880            TokenBreakdownValidation::Counts
3881        );
3882    }
3883
3884    #[test]
3885    fn token_usage_refusals_preserve_turn_usage_state() {
3886        let invalid_notifications = [
3887            json!({"method":"thread/tokenUsage/updated","params":{"threadId":"thr-notification","turnId":"turn-notification"}}),
3888            json!({"method":"thread/tokenUsage/updated","params":{"threadId":"thr-notification","turnId":"turn-notification","tokenUsage":{"last":{"cachedInputTokens":1,"inputTokens":2,"outputTokens":3,"reasoningOutputTokens":4,"totalTokens":10},"total":{"cachedInputTokens":1,"inputTokens":2,"outputTokens":3,"reasoningOutputTokens":4,"totalTokens":10},"modelContextWindow":-1}}}),
3889            json!({"method":"thread/tokenUsage/updated","params":{"threadId":"thr-notification","turnId":"turn-notification","tokenUsage":{"last":{"cachedInputTokens":1,"inputTokens":2,"outputTokens":3,"reasoningOutputTokens":4,"totalTokens":10,"future":0},"total":{"cachedInputTokens":1,"inputTokens":2,"outputTokens":3,"reasoningOutputTokens":4,"totalTokens":10}}}}),
3890        ];
3891        for (index, notification) in invalid_notifications.into_iter().enumerate() {
3892            let mut runtime = completable_runtime();
3893            assert_eq!(
3894                ingest(&mut runtime, notification),
3895                Err(RuntimeError::ProtocolFailure),
3896                "token usage refusal {index}"
3897            );
3898            assert!(
3899                runtime
3900                    .turns
3901                    .get(&(
3902                        String::from("thr-notification"),
3903                        String::from("turn-notification")
3904                    ))
3905                    .is_some_and(|turn| turn.usage.is_none())
3906            );
3907        }
3908    }
3909
3910    #[test]
3911    fn token_usage_correlation_markers_commit_only_ids_and_preserve_refusal_state() {
3912        let runtime = completable_runtime();
3913        let incoming_thread_id = "thread-private-canary";
3914        let incoming_turn_id = "turn-private-canary";
3915        let marker = token_usage_correlation_marker(
3916            TokenUsageCorrelationRefusal::MissingPair,
3917            incoming_thread_id,
3918            incoming_turn_id,
3919            &runtime.turns,
3920        );
3921
3922        assert!(marker.starts_with(concat!(
3923            "steadymux-owned-child: token-usage-correlation reason=missing-pair incoming-thread-sha256=",
3924            "39b23b6f58c848f74d5936de5a9bd933f4b53a58cef823f75c3eef10a27073ce"
3925        )));
3926        assert!(marker.contains(concat!(
3927            " incoming-turn-sha256=",
3928            "f048ecdb852620d8ac2aed4f4b73b6b087ad0bbf515943205eba3a2bc94a9a13"
3929        )));
3930        assert!(marker.contains(" known-active-threads-sha256="));
3931        assert!(marker.contains(" known-active-turns-sha256="));
3932        assert!(marker.contains(" known-active-pairs-sha256="));
3933        assert!(marker.ends_with(" known-active-pair-count=1"));
3934        for forbidden in [
3935            incoming_thread_id,
3936            incoming_turn_id,
3937            "thr-notification",
3938            "turn-notification",
3939            "private",
3940            "canary",
3941        ] {
3942            assert!(!marker.contains(forbidden), "marker leaked {forbidden}");
3943        }
3944
3945        let mut runtime = completable_runtime();
3946        let mut mismatched = token_usage_notification(12, 34);
3947        mismatched["params"]["threadId"] = json!(incoming_thread_id);
3948        mismatched["params"]["turnId"] = json!(incoming_turn_id);
3949        assert_eq!(
3950            ingest(&mut runtime, mismatched),
3951            Err(RuntimeError::ProtocolFailure),
3952            "the diagnostic must not weaken correlation admission"
3953        );
3954        assert!(
3955            runtime
3956                .turns
3957                .get(&(
3958                    String::from("thr-notification"),
3959                    String::from("turn-notification")
3960                ))
3961                .is_some_and(|turn| turn.usage.is_none()),
3962            "a mismatched update must not consume the active turn usage slot"
3963        );
3964
3965        let mut rerouted = completable_runtime();
3966        rerouted
3967            .turns
3968            .get_mut(&(
3969                String::from("thr-notification"),
3970                String::from("turn-notification"),
3971            ))
3972            .expect("active turn fixture")
3973            .model_rerouted = true;
3974        assert_eq!(
3975            ingest(&mut rerouted, token_usage_notification(12, 34)),
3976            Err(RuntimeError::ProtocolFailure),
3977            "rerouted turns remain ineligible for usage updates"
3978        );
3979        assert!(rerouted.turns.values().all(|turn| turn.usage.is_none()));
3980
3981        let mut duplicate = completable_runtime();
3982        ingest(&mut duplicate, token_usage_notification(12, 34)).expect("first usage update");
3983        let usage_before = duplicate
3984            .turns
3985            .values()
3986            .next()
3987            .expect("active turn fixture")
3988            .usage;
3989        assert_eq!(
3990            ingest(&mut duplicate, token_usage_notification(13, 34)),
3991            Ok(RuntimeEvent::NonSemanticNotificationIgnored),
3992            "a monotonic cumulative projection may replace the per-turn snapshot"
3993        );
3994        assert_eq!(
3995            duplicate
3996                .turns
3997                .values()
3998                .next()
3999                .expect("active turn fixture")
4000                .usage,
4001            Some(TurnUsageCounts {
4002                input_tokens: 13,
4003                output_tokens: 34,
4004            }),
4005            "the latest per-turn snapshot is retained after cumulative admission"
4006        );
4007        assert_ne!(
4008            duplicate
4009                .turns
4010                .values()
4011                .next()
4012                .expect("active turn fixture")
4013                .usage,
4014            usage_before,
4015        );
4016
4017        for reason in [
4018            TokenUsageCorrelationRefusal::MissingPair,
4019            TokenUsageCorrelationRefusal::Rerouted,
4020            TokenUsageCorrelationRefusal::PriorUsage,
4021        ] {
4022            let marker = token_usage_correlation_marker(
4023                reason,
4024                incoming_thread_id,
4025                incoming_turn_id,
4026                &runtime.turns,
4027            );
4028            assert!(marker.contains(&format!(" reason={}", reason.as_str())));
4029        }
4030    }
4031
4032    #[test]
4033    fn cumulative_usage_admission_is_monotonic_and_never_exposes_token_values() {
4034        let baseline = TurnUsageCounts {
4035            input_tokens: 12,
4036            output_tokens: 34,
4037        };
4038        assert_eq!(
4039            prior_usage_direction(
4040                baseline,
4041                TurnUsageCounts {
4042                    input_tokens: 13,
4043                    output_tokens: 34,
4044                },
4045            ),
4046            PriorUsageDirection::MonotonicNondecreasing
4047        );
4048        assert_eq!(
4049            prior_usage_direction(
4050                baseline,
4051                TurnUsageCounts {
4052                    input_tokens: 11,
4053                    output_tokens: 34,
4054                },
4055            ),
4056            PriorUsageDirection::DecreaseOrMixed
4057        );
4058        assert_eq!(
4059            prior_usage_direction(
4060                baseline,
4061                TurnUsageCounts {
4062                    input_tokens: 12,
4063                    output_tokens: 34,
4064                },
4065            ),
4066            PriorUsageDirection::Same
4067        );
4068        assert_eq!(
4069            prior_usage_direction(
4070                baseline,
4071                TurnUsageCounts {
4072                    input_tokens: 13,
4073                    output_tokens: 33,
4074                },
4075            ),
4076            PriorUsageDirection::DecreaseOrMixed
4077        );
4078
4079        assert_eq!(
4080            total_usage_relative_to_last(baseline, cumulative_usage(12, 34),),
4081            TotalUsageRelativeToLast::Equal
4082        );
4083        assert_eq!(
4084            total_usage_relative_to_last(baseline, cumulative_usage(13, 35),),
4085            TotalUsageRelativeToLast::TotalNondecreasing
4086        );
4087        assert_eq!(
4088            total_usage_relative_to_last(baseline, cumulative_usage(11, 35),),
4089            TotalUsageRelativeToLast::TotalDecreaseOrMixed
4090        );
4091        assert_eq!(
4092            cumulative_usage_direction(cumulative_usage(12, 34), cumulative_usage(13, 35)),
4093            PriorUsageDirection::MonotonicNondecreasing
4094        );
4095        assert_eq!(
4096            cumulative_usage_direction(cumulative_usage(12, 34), cumulative_usage(11, 35)),
4097            PriorUsageDirection::DecreaseOrMixed
4098        );
4099        let mut cache_write_decrease = cumulative_usage(13, 35);
4100        cache_write_decrease.cache_write_input_tokens = Some(1);
4101        let mut cache_write_regression = cache_write_decrease;
4102        cache_write_regression.cache_write_input_tokens = Some(0);
4103        assert_eq!(
4104            cumulative_usage_direction(cache_write_decrease, cache_write_regression),
4105            PriorUsageDirection::DecreaseOrMixed,
4106            "all validated cumulative counters participate in the admission guard"
4107        );
4108        // An omitted optional cache-write count is not a regression against a
4109        // previously reported positive value.
4110        let mut cache_write_omitted = cache_write_decrease;
4111        cache_write_omitted.cache_write_input_tokens = None;
4112        assert_eq!(
4113            cumulative_usage_direction(cache_write_decrease, cache_write_omitted),
4114            PriorUsageDirection::MonotonicNondecreasing,
4115            "omitted optional cache-write count must not reset a positive prior"
4116        );
4117
4118        let marker = prior_usage_direction_marker(PriorUsageDirection::DecreaseOrMixed);
4119        assert_eq!(
4120            marker,
4121            "steadymux-owned-child: token-usage-prior-direction=decrease-or-mixed"
4122        );
4123        for forbidden in ["12", "13", "33", "34", "input", "output"] {
4124            assert!(!marker.contains(forbidden), "marker leaked {forbidden}");
4125        }
4126        let projection_marker = prior_usage_projection_marker(
4127            TotalUsageRelativeToLast::TotalNondecreasing,
4128            PriorUsageDirection::MonotonicNondecreasing,
4129        );
4130        assert_eq!(
4131            projection_marker,
4132            "steadymux-owned-child: token-usage-prior-projections total-vs-last=total-nondecreasing total-vs-prior=monotonic-nondecreasing"
4133        );
4134        for forbidden in ["12", "13", "33", "34", "input", "output", "cached"] {
4135            assert!(
4136                !projection_marker.contains(forbidden),
4137                "projection marker leaked {forbidden}"
4138            );
4139        }
4140
4141        let mut runtime = completable_runtime();
4142        ingest(
4143            &mut runtime,
4144            token_usage_notification_with_total(12, 34, 1_000, 2_000),
4145        )
4146        .expect("baseline usage");
4147        let before = runtime.turns.values().next().expect("turn state").usage;
4148        assert_eq!(
4149            ingest(
4150                &mut runtime,
4151                token_usage_notification_with_total(13, 34, 1_010, 2_010),
4152            ),
4153            Ok(RuntimeEvent::NonSemanticNotificationIgnored),
4154            "a complete monotonic total admits the latest per-turn projection"
4155        );
4156        assert_eq!(
4157            runtime.turns.values().next().expect("turn state").usage,
4158            Some(TurnUsageCounts {
4159                input_tokens: 13,
4160                output_tokens: 34,
4161            })
4162        );
4163        assert_ne!(
4164            runtime.turns.values().next().expect("turn state").usage,
4165            before
4166        );
4167        assert_eq!(
4168            ingest(
4169                &mut runtime,
4170                token_usage_notification_with_total(11, 34, 1_009, 2_011),
4171            ),
4172            Err(RuntimeError::ProtocolFailure),
4173            "a decrease in any persisted cumulative counter remains fail closed"
4174        );
4175        assert_eq!(
4176            runtime.turns.values().next().expect("turn state").usage,
4177            Some(TurnUsageCounts {
4178                input_tokens: 13,
4179                output_tokens: 34,
4180            }),
4181            "a refused cumulative projection preserves the last accepted receipt delta"
4182        );
4183        let debug = format!("{runtime:?}");
4184        for forbidden in ["1000", "1010", "2000", "2010"] {
4185            assert!(
4186                !debug.contains(forbidden),
4187                "runtime debug leaked {forbidden}"
4188            );
4189        }
4190
4191        let mut rerouted = completable_runtime();
4192        ingest(&mut rerouted, token_usage_notification(12, 34)).expect("baseline usage");
4193        rerouted
4194            .turns
4195            .values_mut()
4196            .next()
4197            .expect("turn state")
4198            .model_rerouted = true;
4199        let before = rerouted.turns.values().next().expect("turn state").usage;
4200        assert_eq!(
4201            ingest(&mut rerouted, token_usage_notification(12, 34)),
4202            Err(RuntimeError::ProtocolFailure),
4203            "rerouted turns refuse even an otherwise-idempotent usage update"
4204        );
4205        assert_eq!(
4206            rerouted.turns.values().next().expect("turn state").usage,
4207            before,
4208            "rerouted refusal preserves previously admitted usage"
4209        );
4210    }
4211
4212    #[test]
4213    fn malformed_remote_control_status_notifications_fail_closed() {
4214        let malformed = [
4215            json!({"status":"enabled","serverName":"server","installationId":"install","environmentId":null}),
4216            json!({"status":"disabled","serverName":"","installationId":"install","environmentId":null}),
4217            json!({"status":"disabled","serverName":"server","installationId":"","environmentId":null}),
4218            json!({"status":"disabled","serverName":"server","installationId":"install","environmentId":"environment"}),
4219            json!({"status":"disabled","serverName":"server","installationId":"install","environmentId":null,"future":true}),
4220        ];
4221
4222        for params in malformed {
4223            let mut runtime = CodexAgentRuntime::new();
4224            assert_eq!(
4225                ingest(
4226                    &mut runtime,
4227                    json!({"method":"remoteControl/status/changed","params":params,"emittedAtMs":1}),
4228                ),
4229                Err(RuntimeError::ProtocolFailure)
4230            );
4231        }
4232    }
4233
4234    #[test]
4235    fn unknown_notifications_still_fail_closed() {
4236        let mut runtime = CodexAgentRuntime::new();
4237        let canary = "private-payload-canary";
4238        let error = ingest(
4239            &mut runtime,
4240            json!({"method":"thread/future","params":{"content":canary},"emittedAtMs":1}),
4241        )
4242        .expect_err("unknown notification must remain fail closed");
4243        let RuntimeError::UnsupportedNotificationMethod(method) = error else {
4244            panic!("unknown notification must return a method-only diagnostic");
4245        };
4246        assert_eq!(method.as_str(), "thread/future");
4247        assert!(!format!("{method:?} {method}").contains(canary));
4248    }
4249
4250    #[test]
4251    fn side_effecting_item_refusal_markers_are_content_free_and_policy_specific() {
4252        let item_type = "futureToolWithPrivatePayload";
4253        let type_marker = side_effecting_item_type_marker(item_type);
4254        assert_eq!(
4255            type_marker,
4256            concat!(
4257                "steadymux-owned-child: side-effecting-item-type-sha256=",
4258                "b13049c568e4b6f1440b283578562022dc3e6a51c357527f133126acd750ee75"
4259            )
4260        );
4261        let type_marker = side_effecting_item_type_marker(item_type);
4262        assert!(!type_marker.contains(item_type));
4263        assert!(!type_marker.contains("PrivatePayload"));
4264        let mcp_item = json!({
4265            "status": "inProgress",
4266            "server": "mcp-server-private-canary",
4267            "tool": "mcp-tool-private-canary",
4268            "arguments": {"content": "NO_RETENTION_MCP_ARGUMENT_CANARY"},
4269        });
4270        assert_eq!(
4271            mcp_tool_policy_refusal_marker(&mcp_item, McpToolLifecycle::Started),
4272            concat!(
4273                "steadymux-owned-child: side-effecting-item-refused=mcp-tool-policy ",
4274                "lifecycle=started status=in-progress ",
4275                "server-sha256=424957af3b15be998a0143b68e0adc27ec7d06e8521b4045f03525fcbae5765d ",
4276                "tool-sha256=0cec75c422c7b03194148b7e50165b45a60a0868d5960d449b9b2e5376d923a6"
4277            )
4278        );
4279        let marker = mcp_tool_policy_refusal_marker(&mcp_item, McpToolLifecycle::Started);
4280        assert!(!marker.contains("mcp-server-private-canary"));
4281        assert!(!marker.contains("mcp-tool-private-canary"));
4282        assert!(!marker.contains("NO_RETENTION_MCP_ARGUMENT_CANARY"));
4283        assert_eq!(
4284            mcp_tool_policy_refusal_marker(&json!({}), McpToolLifecycle::Completed),
4285            concat!(
4286                "steadymux-owned-child: side-effecting-item-refused=mcp-tool-policy ",
4287                "lifecycle=completed status=unknown ",
4288                "server-sha256=missing-or-invalid tool-sha256=missing-or-invalid"
4289            )
4290        );
4291    }
4292
4293    #[test]
4294    fn rejected_item_types_remain_unretained_and_fail_closed() {
4295        let canary = "NO_RETENTION_ITEM_CANARY";
4296        for (method, item_type) in [
4297            ("item/started", "futureToolWithPrivatePayload"),
4298            ("item/completed", "futureCompletedToolWithPrivatePayload"),
4299        ] {
4300            let mut runtime = active_reasoning_runtime();
4301            assert_eq!(
4302                ingest(
4303                    &mut runtime,
4304                    json!({
4305                        "method": method,
4306                        "params": {
4307                            "threadId": "thr-notification",
4308                            "turnId": "turn-notification",
4309                            "item": {
4310                                "type": item_type,
4311                                "id": "side-effecting-item",
4312                                "private": canary,
4313                            },
4314                        },
4315                    }),
4316                ),
4317                Err(RuntimeError::SideEffectingItemRejected),
4318                "{method} must remain fail closed"
4319            );
4320            let turn = runtime
4321                .turns
4322                .values()
4323                .next()
4324                .expect("active turn remains available for terminal receipt");
4325            assert!(turn.tool_or_side_effect);
4326            assert_eq!(turn.items.len(), 1);
4327            let debug = format!("{runtime:?}");
4328            assert!(!debug.contains(item_type));
4329            assert!(!debug.contains(canary));
4330        }
4331    }
4332
4333    #[test]
4334    fn web_search_items_remain_fail_closed_even_if_a_child_flag_is_omitted() {
4335        let canary = "NO_RETENTION_WEB_SEARCH_CANARY";
4336        for (method, item_type) in [
4337            ("item/started", "webSearch"),
4338            ("item/completed", "webSearch"),
4339        ] {
4340            let mut runtime = active_reasoning_runtime();
4341            assert_eq!(
4342                ingest(
4343                    &mut runtime,
4344                    json!({
4345                        "method": method,
4346                        "params": {
4347                            "threadId": "thr-notification",
4348                            "turnId": "turn-notification",
4349                            "item": {
4350                                "type": item_type,
4351                                "id": "web-search-item",
4352                                "private": canary,
4353                            },
4354                        },
4355                    }),
4356                ),
4357                Err(RuntimeError::SideEffectingItemRejected),
4358                "{method} web-search item must remain fail closed"
4359            );
4360            let turn = runtime
4361                .turns
4362                .values()
4363                .next()
4364                .expect("active turn remains available for terminal receipt");
4365            assert!(turn.tool_or_side_effect);
4366            assert!(!format!("{runtime:?}").contains(canary));
4367        }
4368    }
4369
4370    #[test]
4371    fn no_tools_mcp_refusal_remains_fail_closed_with_digest_only_identity_markers() {
4372        let canary = "NO_RETENTION_MCP_CANARY";
4373        for (method, status, lifecycle) in [
4374            ("item/started", "inProgress", McpToolLifecycle::Started),
4375            ("item/completed", "completed", McpToolLifecycle::Completed),
4376        ] {
4377            let mut runtime = active_reasoning_runtime();
4378            let item = json!({
4379                "type": "mcpToolCall",
4380                "id": "mcp-tool-item",
4381                "status": status,
4382                "server": "mcp-server-private-canary",
4383                "tool": "mcp-tool-private-canary",
4384                "arguments": {"canary": canary},
4385                "result": {"content": "NO_RETENTION_MCP_RESULT_CANARY"},
4386                "error": null,
4387            });
4388            assert_eq!(
4389                ingest(
4390                    &mut runtime,
4391                    json!({
4392                        "method": method,
4393                        "params": {
4394                            "threadId": "thr-notification",
4395                            "turnId": "turn-notification",
4396                            "item": item,
4397                        },
4398                    }),
4399                ),
4400                Err(RuntimeError::SideEffectingItemRejected),
4401                "{method} must reject all MCP lifecycle events"
4402            );
4403            let turn = runtime
4404                .turns
4405                .values()
4406                .next()
4407                .expect("active turn remains available");
4408            assert!(turn.tool_or_side_effect);
4409            assert!(!format!("{runtime:?}").contains(canary));
4410            let marker = mcp_tool_policy_refusal_marker(
4411                &json!({
4412                    "status": status,
4413                    "server": "mcp-server-private-canary",
4414                    "tool": "mcp-tool-private-canary",
4415                    "arguments": {"canary": canary},
4416                }),
4417                lifecycle,
4418            );
4419            assert!(!marker.contains(canary));
4420            assert!(!marker.contains("mcp-server-private-canary"));
4421            assert!(!marker.contains("mcp-tool-private-canary"));
4422        }
4423    }
4424}