Skip to main content

bamboo_subagent/
proto.rs

1//! Wire protocol: discovery record + parent/child WebSocket frames.
2//!
3//! The session/event payloads are kept opaque (`serde_json::Value`) so this crate stays a leaf;
4//! the real `AgentEvent` serializes into a sequenced [`ActorEventBatch`]. The legacy
5//! [`ChildFrame::Event`] remains decodable during rolling upgrades.
6
7use bamboo_domain::{ProjectId, SessionActivationPolicy, SessionMessageEnvelope};
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10
11/// Tier-1 discovery record an actor publishes into the file fabric so others can find it.
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
13pub struct AgentRecord {
14    pub agent_id: String,
15    pub role: String,
16    #[serde(default)]
17    pub labels: Vec<String>,
18    /// `ws://127.0.0.1:<port>` reachable endpoint.
19    pub endpoint: String,
20    pub pid: u32,
21    #[serde(default)]
22    pub version: String,
23    pub started_at: DateTime<Utc>,
24    /// Lease: a reader treats the record as stale once `now > lease_expires_at`.
25    pub lease_expires_at: DateTime<Utc>,
26}
27
28/// A unit of work a parent assigns to an actor.
29#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
30pub struct RunSpec {
31    pub assignment: String,
32    /// Stable domain identity for the session being activated. Actor process,
33    /// mailbox, and pooled-worker ids are transport details and must never
34    /// replace these values in worker persistence or message routing.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub logical_session: Option<LogicalSessionIdentity>,
37    /// Stable Project identity inherited from the parent session. The typed
38    /// wire value rejects unsafe/invalid identifiers during deserialization.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub project_id: Option<ProjectId>,
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub reasoning_effort: Option<String>,
43    /// Effective permission policy captured by the host at this activation
44    /// boundary. Keeping it on `RunSpec` (rather than only provisioning) lets
45    /// warm, broker and remote workers observe policy revisions and bypass
46    /// changes on their next activation.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub permission_policy: Option<PermissionPolicyContext>,
49    /// Full prior conversation (serialized domain `Message`s, oldest first),
50    /// INCLUDING the assignment's user message when present. The actor's
51    /// durable state lives in the parent's store; each activation rehydrates
52    /// from here — this is what makes send_message/update/rerun carry context
53    /// across one-shot actor processes. Empty = first activation, no history.
54    #[serde(default, skip_serializing_if = "Vec::is_empty")]
55    pub messages: Vec<serde_json::Value>,
56    /// Independently authoritative id of the host activation whose execution
57    /// this RunSpec starts. Initial and mid-run typed deliveries must match it;
58    /// a delivery's own run-id field is never accepted as self-authentication.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub activation_run_id: Option<String>,
61    /// Host-issued fencing epoch for this concrete execution attempt. A retry
62    /// on a different worker gets a newer epoch even when it belongs to the
63    /// same logical activation, so late frames from the replaced worker can be
64    /// rejected. Zero is reserved for legacy senders and selects the legacy
65    /// one-event wire shape on a new worker during rolling upgrades.
66    #[serde(default, skip_serializing_if = "is_zero")]
67    pub execution_epoch: u64,
68    /// Canonical logical-session deliveries that caused this idle actor
69    /// activation. The worker durably enqueues these before entering its first
70    /// provider boundary, then confirms admission over the child frame stream.
71    #[serde(default, skip_serializing_if = "Vec::is_empty")]
72    pub initial_session_messages: Vec<SessionMessageDelivery>,
73    /// Secrets minted for this activation only. They are delivered in-memory
74    /// over the actor transport and must never be persisted by the worker.
75    #[serde(default, skip_serializing_if = "RunSecrets::is_empty")]
76    pub secrets: RunSecrets,
77}
78
79/// Host-selected Workflow usage observation on the existing activation event lane.
80/// Counts use canonical runtime accounting (provider-first, estimate fallback),
81/// not monetary billing. This observation grants no execution authority.
82pub const WORKFLOW_USAGE_REQUESTED_KEY: &str = "workflow.agent_usage_requested.v1";
83pub const WORKFLOW_USAGE_OBSERVATION_KEY: &str = "workflow.agent_usage_observation.v1";
84/// Host-only proof that the selected current activation delivered an accepted terminal.
85pub const WORKFLOW_TERMINAL_OBSERVATION_KEY: &str = "workflow.agent_terminal_observation.v1";
86
87#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
88#[serde(deny_unknown_fields)]
89pub struct WorkflowAgentUsage {
90    #[serde(rename = "type")]
91    pub kind: String,
92    pub activation_run_id: String,
93    pub child_session_id: String,
94    pub child_created_at: chrono::DateTime<chrono::Utc>,
95    pub prompt_tokens: u64,
96    pub completion_tokens: u64,
97}
98
99impl WorkflowAgentUsage {
100    pub const TYPE: &'static str = "workflow_agent_usage";
101
102    pub fn matches(&self, child: &bamboo_domain::Session, activation: &str) -> bool {
103        self.kind == Self::TYPE
104            && !activation.is_empty()
105            && self.activation_run_id == activation
106            && self.child_session_id == child.id
107            && self.child_created_at == child.created_at
108    }
109
110    pub fn from_session(child: &bamboo_domain::Session, activation: &str) -> Option<Self> {
111        let usage: Self =
112            serde_json::from_str(child.metadata.get(WORKFLOW_USAGE_OBSERVATION_KEY)?).ok()?;
113        usage.matches(child, activation).then_some(usage)
114    }
115
116    pub fn total_tokens(&self) -> u64 {
117        self.prompt_tokens.saturating_add(self.completion_tokens)
118    }
119}
120
121/// Actual worker message suffix, carried in the existing sequenced event lane.
122/// This cache observation grants nothing; the Host separately validates its
123/// current callable ceiling, event trace and fenced canonical append.
124#[derive(Debug, Clone, Serialize, Deserialize)]
125#[serde(tag = "type", deny_unknown_fields)]
126pub enum ReadOnlyActorTranscript {
127    #[serde(rename = "owned_readonly_transcript")]
128    Complete { messages: Vec<serde_json::Value> },
129}
130impl ReadOnlyActorTranscript {
131    pub const MAX_BYTES: usize = 64 * 1024;
132    pub fn validate(&self) -> Result<(), &'static str> {
133        let Self::Complete { messages } = self;
134        if messages.len() != 3
135            || serde_json::to_vec(self).map_or(true, |bytes| bytes.len() > Self::MAX_BYTES)
136        {
137            return Err("owned_readonly_transcript_unsupported");
138        }
139        Ok(())
140    }
141}
142
143/// Completion DATA on the existing durable event lane, never Session authority.
144#[derive(Debug, Clone, Serialize, Deserialize)]
145#[serde(tag = "type", deny_unknown_fields)]
146pub enum LocalToolMessages {
147    #[serde(rename = "local_client_tool_messages_v1")]
148    Complete {
149        version: u32,
150        messages: Vec<serde_json::Value>,
151    },
152}
153
154impl LocalToolMessages {
155    pub const TYPE: &'static str = "local_client_tool_messages_v1";
156    pub const MAX_BYTES: usize = 64 * 1024;
157    pub const MAX_MESSAGES: usize = 128;
158    pub const MAX_PAIRS: usize = 32;
159
160    pub fn supports_tools(tools: &[String], read_only: bool) -> bool {
161        if tools.iter().any(|name| name == "Task") {
162            return tools
163                .iter()
164                .all(|name| matches!(name.as_str(), "Task" | "Read" | "Write"));
165        }
166        !tools.is_empty()
167            && !(read_only && tools.len() == 1 && tools[0] == "Glob")
168            && tools
169                .iter()
170                .all(|name| matches!(name.as_str(), "Read" | "Glob" | "Write"))
171    }
172
173    pub fn validate(&self) -> Result<Vec<bamboo_domain::Message>, &'static str> {
174        use std::io::Write;
175        struct Limit(usize);
176        impl Write for Limit {
177            fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
178                self.0 = self
179                    .0
180                    .checked_add(bytes.len())
181                    .filter(|n| *n <= LocalToolMessages::MAX_BYTES)
182                    .ok_or_else(|| std::io::Error::other("local tool history limit"))?;
183                Ok(bytes.len())
184            }
185            fn flush(&mut self) -> std::io::Result<()> {
186                Ok(())
187            }
188        }
189        let Self::Complete { version, messages } = self;
190        if *version != 1
191            || messages.is_empty()
192            || messages.len() > Self::MAX_MESSAGES
193            || serde_json::to_writer(Limit(0), self).is_err()
194        {
195            return Err("local_tool_history_unsupported");
196        }
197        let typed: Vec<bamboo_domain::Message> = messages
198            .iter()
199            .map(|value| {
200                let message: bamboo_domain::Message = serde_json::from_value(value.clone())
201                    .map_err(|_| "local_tool_history_unsupported")?;
202                if serde_json::to_value(&message).map_err(|_| "local_tool_history_unsupported")?
203                    != *value
204                    || message.id.is_empty()
205                    || message.id.len() > 128
206                    || (message.reasoning.is_some()
207                        && message.role != bamboo_domain::Role::Assistant)
208                    || message.reasoning_signature.is_some()
209                    || message.content_parts.is_some()
210                    || message.image_ocr.is_some()
211                    || message.compressed
212                    || message.compressed_by_event_id.is_some()
213                    || message.compression_level != 0
214                {
215                    return Err("local_tool_history_unsupported");
216                }
217                Ok(message)
218            })
219            .collect::<Result<_, _>>()?;
220        let mut ids = std::collections::HashSet::new();
221        let mut calls = std::collections::HashSet::new();
222        for message in &typed {
223            if !ids.insert(&message.id) {
224                return Err("local_tool_history_unsupported");
225            }
226            for call in message.tool_calls.iter().flatten() {
227                if call.id.is_empty()
228                    || call.id.len() > 128
229                    || !calls.insert(&call.id)
230                    || calls.len() > Self::MAX_PAIRS
231                {
232                    return Err("local_tool_history_unsupported");
233                }
234            }
235        }
236        Ok(typed)
237    }
238}
239
240/// Logical session ancestry carried across every actor placement.
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
242pub struct LogicalSessionIdentity {
243    pub session_id: String,
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub parent_session_id: Option<String>,
246    pub root_session_id: String,
247    /// Host-authored immutable Child birth. Missing only on legacy routes;
248    /// this is identity, not task or permission authority.
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub creation: Option<ChildCreationIdentity>,
251}
252
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
254#[serde(deny_unknown_fields)]
255pub struct ChildCreationIdentity {
256    pub created_at: DateTime<Utc>,
257    pub spawn_depth: u32,
258}
259
260/// Immutable one-shot native name ceiling. It is startup authority, not a
261/// persisted profile or a grant for workspace/network/secret access.
262#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
263#[serde(deny_unknown_fields)]
264pub struct NativeToolCeiling {
265    pub version: u32,
266    pub child_session_id: String,
267    pub parent_session_id: String,
268    pub root_session_id: String,
269    pub created_at: DateTime<Utc>,
270    pub spawn_depth: u32,
271    // Explicit null means unassigned; omission is not an authority observation.
272    #[serde(deserialize_with = "deserialize_project_observation")]
273    pub project_id: Option<ProjectId>,
274    pub tools: Vec<String>,
275}
276
277fn deserialize_project_observation<'de, D: serde::Deserializer<'de>>(
278    deserializer: D,
279) -> Result<Option<ProjectId>, D::Error> {
280    Option::<ProjectId>::deserialize(deserializer)
281}
282
283impl NativeToolCeiling {
284    pub const NAMES: [&'static str; 8] = [
285        "Bash",
286        "Edit",
287        "Glob",
288        "Read",
289        "Task",
290        "Write",
291        "exec_command",
292        "write_stdin",
293    ];
294    pub const MAX_BYTES: usize = 16 * 1024;
295
296    pub fn validate(&self) -> Result<(), &'static str> {
297        let mut candidate = bamboo_domain::Session::new(self.child_session_id.clone(), "");
298        candidate.kind = bamboo_domain::SessionKind::Child;
299        candidate.parent_session_id = Some(self.parent_session_id.clone());
300        candidate.root_session_id = self.root_session_id.clone();
301        candidate.created_at = self.created_at;
302        candidate.spawn_depth = self.spawn_depth;
303        if self.version != 1
304            || bamboo_domain::ActorSession::from_session(&candidate).is_err()
305            || self.tools.len() > Self::NAMES.len()
306            || self
307                .tools
308                .iter()
309                .any(|name| !Self::NAMES.contains(&name.as_str()))
310            || self.tools.windows(2).any(|pair| pair[0] >= pair[1])
311            || serde_json::to_vec(self).map_or(true, |bytes| bytes.len() > Self::MAX_BYTES)
312        {
313            return Err("native_tool_ceiling_invalid");
314        }
315        Ok(())
316    }
317
318    pub fn matches_run(&self, run: &RunSpec) -> bool {
319        self.validate().is_ok()
320            && run.project_id == self.project_id
321            && run.logical_session.as_ref().is_some_and(|identity| {
322                identity.session_id == self.child_session_id
323                    && identity.parent_session_id.as_deref()
324                        == Some(self.parent_session_id.as_str())
325                    && identity.root_session_id == self.root_session_id
326                    && identity.creation.as_ref().is_some_and(|creation| {
327                        creation.created_at == self.created_at
328                            && creation.spawn_depth == self.spawn_depth
329                    })
330            })
331    }
332}
333
334fn is_zero(value: &u64) -> bool {
335    *value == 0
336}
337
338/// Delivery semantics for one actor event batch.
339///
340/// `Durable` batches must use the broker's acknowledged mailbox lane.
341/// An executor requiring complete history evidence may upgrade a batch to
342/// `Durable`. `Snapshot` and `Ephemeral` batches may use the bounded live lane: sequence
343/// gaps tell a consumer to reload the authoritative session snapshot.
344#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
345#[serde(rename_all = "snake_case")]
346pub enum ActorEventQos {
347    Durable,
348    Snapshot,
349    Ephemeral,
350}
351
352impl ActorEventQos {
353    /// Classify opaque serialized `AgentEvent` JSON without making this leaf
354    /// crate depend on `bamboo-agent-core`.
355    pub fn classify(event: &serde_json::Value) -> Self {
356        match event.get("type").and_then(serde_json::Value::as_str) {
357            Some("token" | "reasoning_token" | "tool_token" | "sub_agent_heartbeat") => {
358                Self::Ephemeral
359            }
360            // A raw child projection inherits the inner event's semantics. New
361            // hosts no longer recursively project these to parents, but this is
362            // needed for rolling-upgrade workers that still do.
363            Some("sub_agent_event") => event
364                .get("event")
365                .map(Self::classify)
366                .unwrap_or(Self::Durable),
367            Some("runner_progress" | "token_budget_updated" | "context_pressure_notification") => {
368                Self::Snapshot
369            }
370            // This is a versioned delta, not a reconstructable full snapshot.
371            // Losing it can leave task state behind even when later unrelated
372            // events arrive, and core exposes it on the durable account feed.
373            Some("task_list_item_progress") => Self::Durable,
374            // Unknown events are never silently downgraded onto a lossy lane.
375            _ => Self::Durable,
376        }
377    }
378}
379
380/// Maximum event count accepted in one actor wire batch. This bounds decode
381/// and fan-out work per frame independently of payload byte limits enforced by
382/// WebSocket implementations.
383pub const MAX_ACTOR_EVENT_BATCH_EVENTS: usize = 64;
384
385/// A compact, ordered actor event batch. Route/fencing metadata is common to
386/// the batch; `first_seq..=last_seq` assigns one sequence number per item in
387/// `events` and is scoped to `(logical session, activation, execution epoch)`.
388#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
389pub struct ActorEventBatch {
390    #[serde(default, skip_serializing_if = "Option::is_none")]
391    pub logical_session: Option<LogicalSessionIdentity>,
392    #[serde(default, skip_serializing_if = "Option::is_none")]
393    pub activation_id: Option<String>,
394    #[serde(default, skip_serializing_if = "is_zero")]
395    pub execution_epoch: u64,
396    #[serde(default, skip_serializing_if = "Option::is_none")]
397    pub source_node_id: Option<String>,
398    #[serde(default, skip_serializing_if = "Option::is_none")]
399    pub source_actor_id: Option<String>,
400    pub first_seq: u64,
401    pub last_seq: u64,
402    pub qos: ActorEventQos,
403    pub events: Vec<serde_json::Value>,
404}
405
406impl ActorEventBatch {
407    /// Reject malformed ranges, oversized batches, and QoS downgrades before a
408    /// broker routes the frame onto a lossy lane.
409    pub fn validate(&self) -> Result<(), String> {
410        if self.events.is_empty() {
411            return Err("actor event batch is empty".to_string());
412        }
413        if self.events.len() > MAX_ACTOR_EVENT_BATCH_EVENTS {
414            return Err(format!(
415                "actor event batch has {} events; maximum is {MAX_ACTOR_EVENT_BATCH_EVENTS}",
416                self.events.len()
417            ));
418        }
419        let expected_last = self
420            .first_seq
421            .checked_add(self.events.len() as u64 - 1)
422            .ok_or_else(|| "actor event batch sequence range overflows".to_string())?;
423        if self.first_seq == 0 || self.last_seq != expected_last {
424            return Err("actor event batch has an invalid sequence range".to_string());
425        }
426        if self.qos != ActorEventQos::Durable
427            && self
428                .events
429                .iter()
430                .any(|event| ActorEventQos::classify(event) != self.qos)
431        {
432            return Err("actor event batch QoS does not match its events".to_string());
433        }
434        Ok(())
435    }
436}
437
438/// A current-Run completeness claim for the existing contiguous history lane.
439/// This is not a durable applied cursor or a replay/recovery acknowledgement.
440#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
441pub struct ActorEventWatermark {
442    pub version: u32,
443    pub logical_session: Option<LogicalSessionIdentity>,
444    pub activation_id: Option<String>,
445    pub execution_epoch: u64,
446    /// Last sequenced event after the transport drained and flushed; zero means
447    /// that this Run emitted no events.
448    pub final_seq: u64,
449}
450
451impl ActorEventWatermark {
452    pub const VERSION: u32 = 1;
453
454    pub fn matches_consumed_run(
455        &self,
456        logical_session: &LogicalSessionIdentity,
457        activation_id: &str,
458        execution_epoch: u64,
459        consumed_seq: u64,
460    ) -> bool {
461        self.version == Self::VERSION
462            && !activation_id.is_empty()
463            && execution_epoch != 0
464            && self.logical_session.as_ref() == Some(logical_session)
465            && self.activation_id.as_deref() == Some(activation_id)
466            && self.execution_epoch == execution_epoch
467            && self.final_seq == consumed_seq
468    }
469}
470
471#[derive(Debug)]
472struct PendingActorEventBatch {
473    first_seq: u64,
474    qos: ActorEventQos,
475    events: Vec<serde_json::Value>,
476}
477
478/// Per-run event batch builder shared by direct and broker transports. Durable
479/// events flush immediately; snapshot/ephemeral events coalesce until a QoS
480/// boundary, size bound, or the caller's latency timer fires.
481#[derive(Debug)]
482pub struct ActorEventBatcher {
483    logical_session: Option<LogicalSessionIdentity>,
484    activation_id: Option<String>,
485    execution_epoch: u64,
486    source_node_id: Option<String>,
487    source_actor_id: Option<String>,
488    next_seq: u64,
489    pending: Option<PendingActorEventBatch>,
490    durable_events: bool,
491}
492
493impl ActorEventBatcher {
494    pub fn for_run(
495        spec: &RunSpec,
496        source_node_id: Option<String>,
497        source_actor_id: Option<String>,
498    ) -> Self {
499        Self {
500            logical_session: spec.logical_session.clone(),
501            activation_id: spec.activation_run_id.clone(),
502            execution_epoch: spec.execution_epoch,
503            source_node_id,
504            source_actor_id,
505            next_seq: 1,
506            pending: None,
507            durable_events: false,
508        }
509    }
510
511    /// Preserve the bounded coalescing/flush policy while delivering every
512    /// batch through the existing reliable lane for strict history consumers.
513    pub fn with_durable_events(mut self, required: bool) -> Self {
514        self.durable_events = required;
515        self
516    }
517
518    /// Add one event and return every batch that became ready. At most two are
519    /// returned: an older lossy batch followed by an immediate durable event.
520    pub fn push(&mut self, event: serde_json::Value) -> Vec<ActorEventBatch> {
521        let qos = ActorEventQos::classify(&event);
522        let seq = self.next_seq;
523        self.next_seq = self.next_seq.saturating_add(1);
524        let mut ready = Vec::with_capacity(2);
525
526        if qos == ActorEventQos::Durable {
527            if let Some(batch) = self.flush() {
528                ready.push(batch);
529            }
530            ready.push(self.build(seq, qos, vec![event]));
531            return ready;
532        }
533
534        let boundary = self.pending.as_ref().is_some_and(|pending| {
535            pending.qos != qos || pending.events.len() >= MAX_ACTOR_EVENT_BATCH_EVENTS
536        });
537        if boundary {
538            if let Some(batch) = self.flush() {
539                ready.push(batch);
540            }
541        }
542        let pending = self.pending.get_or_insert_with(|| PendingActorEventBatch {
543            first_seq: seq,
544            qos,
545            events: Vec::with_capacity(MAX_ACTOR_EVENT_BATCH_EVENTS),
546        });
547        pending.events.push(event);
548        if pending.events.len() >= MAX_ACTOR_EVENT_BATCH_EVENTS {
549            if let Some(batch) = self.flush() {
550                ready.push(batch);
551            }
552        }
553        ready
554    }
555
556    pub fn flush(&mut self) -> Option<ActorEventBatch> {
557        let pending = self.pending.take()?;
558        Some(self.build(pending.first_seq, pending.qos, pending.events))
559    }
560
561    pub fn has_pending(&self) -> bool {
562        self.pending.is_some()
563    }
564
565    /// Call only after every returned batch was successfully forwarded and the
566    /// final flush succeeded. Ordinary lossy and legacy runs make no complete
567    /// trace claim. The caller, not this builder, owns delivery confirmation.
568    pub fn final_watermark(&self) -> Option<ActorEventWatermark> {
569        (self.durable_events
570            && self.execution_epoch != 0
571            && !self.has_pending()
572            // Saturated native coordinates cannot prove a complete prefix.
573            && self.next_seq < u64::MAX)
574            .then(|| ActorEventWatermark {
575                version: ActorEventWatermark::VERSION,
576                logical_session: self.logical_session.clone(),
577                activation_id: self.activation_id.clone(),
578                execution_epoch: self.execution_epoch,
579                final_seq: self.next_seq - 1,
580            })
581    }
582
583    fn build(
584        &self,
585        first_seq: u64,
586        qos: ActorEventQos,
587        events: Vec<serde_json::Value>,
588    ) -> ActorEventBatch {
589        let last_seq = first_seq + events.len() as u64 - 1;
590        ActorEventBatch {
591            logical_session: self.logical_session.clone(),
592            activation_id: self.activation_id.clone(),
593            execution_epoch: self.execution_epoch,
594            source_node_id: self.source_node_id.clone(),
595            source_actor_id: self.source_actor_id.clone(),
596            first_seq,
597            last_seq,
598            qos: if self.durable_events {
599                ActorEventQos::Durable
600            } else {
601                qos
602            },
603            events,
604        }
605    }
606}
607
608/// One canonical inbox claim forwarded to an active actor. The activation run
609/// id and claim generation make the worker's confirmation unambiguous even if
610/// a stale connection delivers a late frame after a successor has taken over.
611#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
612pub struct SessionMessageDelivery {
613    pub target_session_id: String,
614    pub envelope: SessionMessageEnvelope,
615    pub canonical_claim_generation: u64,
616    pub activation_run_id: String,
617    /// Durable host policy associated with the authorized claim prefix. The
618    /// worker mirrors it onto its local receipt before the safe-turn boundary.
619    #[serde(default)]
620    pub activation_policy: SessionActivationPolicy,
621}
622
623/// Worker proof that its local safe-turn path durably checkpointed and acked a
624/// forwarded envelope. The host still has to checkpoint the canonical logical
625/// transcript before it may ack the canonical claim.
626#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
627pub struct SessionMessageAdmissionConfirmation {
628    pub target_session_id: String,
629    pub envelope_id: String,
630    pub canonical_claim_generation: u64,
631    pub activation_run_id: String,
632}
633
634/// Closed initial-input barrier. This is not a tool grant or an approval reply.
635#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
636#[serde(deny_unknown_fields)]
637pub struct InitialInputReleaseRequest {
638    pub version: u32,
639    pub nonce: String,
640    pub child_id: String,
641    pub parent_id: String,
642    pub root_id: String,
643    pub created_at: DateTime<Utc>,
644    pub spawn_depth: u32,
645    #[serde(deserialize_with = "deserialize_project_observation")]
646    pub project_id: Option<ProjectId>,
647    pub envelope_id: String,
648    pub generation: u64,
649    pub activation_run_id: String,
650    pub execution_epoch: u64,
651}
652impl InitialInputReleaseRequest {
653    pub fn from_run(
654        run: &RunSpec,
655        delivery: &SessionMessageDelivery,
656        nonce: String,
657    ) -> Result<Self, String> {
658        let logical = run
659            .logical_session
660            .as_ref()
661            .ok_or("initial release logical identity missing")?;
662        let birth = logical
663            .creation
664            .as_ref()
665            .ok_or("initial release birth missing")?;
666        let parent = logical
667            .parent_session_id
668            .as_ref()
669            .ok_or("initial release parent missing")?;
670        if uuid::Uuid::parse_str(&nonce).is_err()
671            || nonce.len() != 36
672            || logical.session_id.is_empty()
673            || parent.is_empty()
674            || logical.root_session_id.is_empty()
675            || birth.spawn_depth == 0
676            || run.execution_epoch == 0
677            || delivery.canonical_claim_generation == 0
678            || run.activation_run_id.as_deref() != Some(delivery.activation_run_id.as_str())
679            || delivery.target_session_id != logical.session_id
680            || delivery.envelope.target_session_id != logical.session_id
681        {
682            return Err("initial release binding invalid".into());
683        }
684        let request = Self {
685            version: 1,
686            nonce,
687            child_id: logical.session_id.clone(),
688            parent_id: parent.clone(),
689            root_id: logical.root_session_id.clone(),
690            created_at: birth.created_at,
691            spawn_depth: birth.spawn_depth,
692            project_id: run.project_id.clone(),
693            envelope_id: delivery.envelope.id.as_str().into(),
694            generation: delivery.canonical_claim_generation,
695            activation_run_id: delivery.activation_run_id.clone(),
696            execution_epoch: run.execution_epoch,
697        };
698        request.validate()?;
699        Ok(request)
700    }
701    pub fn validate(&self) -> Result<(), String> {
702        let identity = NativeToolCeiling {
703            version: self.version,
704            child_session_id: self.child_id.clone(),
705            parent_session_id: self.parent_id.clone(),
706            root_session_id: self.root_id.clone(),
707            created_at: self.created_at,
708            spawn_depth: self.spawn_depth,
709            project_id: self.project_id.clone(),
710            tools: Vec::new(),
711        };
712        if identity.validate().is_err()
713            || uuid::Uuid::parse_str(&self.nonce).is_err()
714            || self.nonce.len() != 36
715            || self.generation == 0
716            || self.execution_epoch == 0
717            || self.activation_run_id.is_empty()
718            || self.activation_run_id.len() > 256
719            || bamboo_domain::SessionMessageId::parse(self.envelope_id.clone()).is_err()
720        {
721            return Err("initial release binding invalid".into());
722        }
723        Ok(())
724    }
725}
726#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
727#[serde(deny_unknown_fields)]
728pub struct InitialInputRelease {
729    pub request: InitialInputReleaseRequest,
730    pub expires_at: DateTime<Utc>,
731}
732impl InitialInputRelease {
733    pub fn permits(&self, request: &InitialInputReleaseRequest, now: DateTime<Utc>) -> bool {
734        &self.request == request && request.version == 1 && now < self.expires_at
735    }
736}
737/// Reserved discriminator: unknown or malformed values never become text steering.
738#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
739#[serde(
740    tag = "initial_input_control",
741    rename_all = "snake_case",
742    deny_unknown_fields
743)]
744pub enum InitialInputControl {
745    Request { request: InitialInputReleaseRequest },
746    Release { release: InitialInputRelease },
747}
748impl InitialInputControl {
749    pub fn decode(body: serde_json::Value) -> Result<Self, String> {
750        if serde_json::to_vec(&body).map_or(true, |bytes| bytes.len() > 4096) {
751            return Err("initial release control exceeds bound".into());
752        }
753        let control: Self =
754            serde_json::from_value(body).map_err(|_| "initial release control malformed")?;
755        match &control {
756            Self::Request { request } => request.validate()?,
757            Self::Release { release } => release.request.validate()?,
758        }
759        Ok(control)
760    }
761}
762
763/// Per-activation secret envelope. A Bamboo-routed Codex token lives here so a
764/// warm worker never reuses a credential from an earlier run.
765#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
766pub struct RunSecrets {
767    #[serde(default, skip_serializing_if = "Option::is_none")]
768    pub codex_provider_token: Option<SecretValue>,
769}
770
771impl RunSecrets {
772    pub fn is_empty(&self) -> bool {
773        self.codex_provider_token.is_none()
774    }
775}
776
777/// Serializable secret whose debug representation is always redacted.
778#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
779#[serde(transparent)]
780pub struct SecretValue(String);
781
782impl SecretValue {
783    pub fn new(value: impl Into<String>) -> Self {
784        Self(value.into())
785    }
786
787    pub fn expose(&self) -> &str {
788        &self.0
789    }
790}
791
792impl std::fmt::Debug for SecretValue {
793    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
794        formatter.write_str("SecretValue([REDACTED])")
795    }
796}
797
798/// Host-computed permission state for one actor activation. The policy payload
799/// is opaque here so `bamboo-subagent` remains a transport leaf.
800#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
801pub struct PermissionPolicyContext {
802    pub revision: u64,
803    /// Exact typed session request (`default`, `bypass`, or `auto`). Empty is a
804    /// rolling-upgrade legacy payload and is derived from the booleans below.
805    #[serde(default)]
806    pub requested_mode: String,
807    /// Host-resolved effective mode, including Plan/read-only hard overlays.
808    /// Empty is accepted only for legacy payloads.
809    #[serde(default)]
810    pub effective_mode: String,
811    pub bypass_permissions: bool,
812    #[serde(default)]
813    pub auto_approve_permissions: bool,
814    pub session_id: String,
815    #[serde(default, skip_serializing_if = "Option::is_none")]
816    pub workspace_path: Option<String>,
817    /// A portable, admission-time Git snapshot for a fixed remote worker.
818    /// Remote runs carry no host-absolute workspace path.
819    #[serde(default, skip_serializing_if = "Option::is_none")]
820    pub environment_lease: Option<crate::environment::EnvironmentLease>,
821    /// Session grants are deliberately not inherited across an actor boundary;
822    /// a future opt-in protocol can set this and carry explicit scoped grants.
823    #[serde(default)]
824    pub inherit_session_grants: bool,
825    pub policy: serde_json::Value,
826}
827
828impl PermissionPolicyContext {
829    /// Validate a newly-produced wire posture and decode rolling-upgrade
830    /// payloads. Dual permissive flags are always contradictory: Auto never
831    /// borrows Bypass semantics.
832    pub fn resolved_modes(
833        &self,
834    ) -> Result<
835        (
836            bamboo_domain::SessionPermissionMode,
837            bamboo_domain::PermissionMode,
838        ),
839        String,
840    > {
841        if self.auto_approve_permissions && self.bypass_permissions {
842            return Err(
843                "permission_policy auto_approve_permissions and bypass_permissions are mutually exclusive"
844                    .to_string(),
845            );
846        }
847        let has_requested_mode = !self.requested_mode.is_empty();
848        let has_effective_mode = !self.effective_mode.is_empty();
849        if has_requested_mode != has_effective_mode {
850            return Err(
851                "permission_policy requested_mode and effective_mode must be provided together"
852                    .to_string(),
853            );
854        }
855        let requested = if self.requested_mode.is_empty() {
856            if self.auto_approve_permissions {
857                bamboo_domain::SessionPermissionMode::Auto
858            } else if self.bypass_permissions {
859                bamboo_domain::SessionPermissionMode::Bypass
860            } else {
861                bamboo_domain::SessionPermissionMode::Default
862            }
863        } else {
864            match self.requested_mode.as_str() {
865                "default" => bamboo_domain::SessionPermissionMode::Default,
866                "bypass" => bamboo_domain::SessionPermissionMode::Bypass,
867                "auto" => bamboo_domain::SessionPermissionMode::Auto,
868                other => return Err(format!("invalid requested permission mode '{other}'")),
869            }
870        };
871        let effective = if self.effective_mode.is_empty() {
872            bamboo_domain::resolve_permission_mode(
873                requested,
874                bamboo_domain::PermissionMode::Default,
875            )
876            .effective
877        } else {
878            bamboo_domain::PermissionMode::from_audit_str(&self.effective_mode).ok_or_else(
879                || {
880                    format!(
881                        "invalid effective permission mode '{}'",
882                        self.effective_mode
883                    )
884                },
885            )?
886        };
887        let resolution = bamboo_domain::PermissionModeResolution {
888            requested,
889            effective,
890        };
891        if !resolution.is_consistent() {
892            return Err("permission_policy requested/effective modes are inconsistent".into());
893        }
894        if has_requested_mode {
895            if self.bypass_permissions != resolution.bypass_permissions() {
896                return Err("permission_policy bypass flag disagrees with effective mode".into());
897            }
898            if self.auto_approve_permissions != resolution.suppress_approval_prompts() {
899                return Err(
900                    "permission_policy auto flag disagrees with no-prompt resolution".into(),
901                );
902            }
903        }
904        Ok((requested, effective))
905    }
906}
907
908/// Parent → child control/in-band frames.
909#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
910#[serde(tag = "kind", rename_all = "snake_case")]
911pub enum ParentFrame {
912    Run(RunSpec),
913    Cancel,
914    Message {
915        text: String,
916    },
917    SessionMessage {
918        delivery: SessionMessageDelivery,
919    },
920    InitialInputRelease {
921        release: InitialInputRelease,
922    },
923    /// Reply to a [`ChildFrame::ApprovalRequest`] — the host's human/policy
924    /// decision on a gated tool the worker proxied back (Phase 2 child→parent
925    /// approval delegation). `id` correlates to the request. When
926    /// `approved == true` the worker records the grant locally and proceeds;
927    /// `false` denies the tool.
928    ApprovalReply {
929        id: String,
930        approved: bool,
931    },
932    /// Bounded Host-owned tree page for the active logical Child. A missing
933    /// page is a fail-closed denial; no Session authority travels to Worker.
934    OwnedTreeReply {
935        id: String,
936        #[serde(default, skip_serializing_if = "Option::is_none")]
937        page: Option<serde_json::Value>,
938    },
939    /// Canonical Host result for a logical SubAgent operation in this Run.
940    SubAgentReply {
941        id: String,
942        result: serde_json::Value,
943    },
944}
945
946/// Child → parent event/terminal frames.
947#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
948#[serde(tag = "kind", rename_all = "snake_case")]
949pub enum ChildFrame {
950    /// One agent event, serialized verbatim (the real `AgentEvent` lands here as JSON).
951    Event { event: serde_json::Value },
952    /// Sequenced event batch used by current workers. Route metadata prevents a
953    /// stale/replaced Cluster actor from updating the wrong logical activation.
954    EventBatch { batch: ActorEventBatch },
955    /// The worker hit a tool needing human approval (Phase 2 child→parent
956    /// approval delegation). Proxied to the host — which surfaces it to the
957    /// human via the parent session's pending-question / notification path. The
958    /// host answers with [`ParentFrame::ApprovalReply`] carrying the same `id`.
959    /// `body` carries `{tool_name, permission_type, resource, question}`.
960    ApprovalRequest { id: String, body: serde_json::Value },
961    /// The Worker supplies only a page cursor. The Host binds this frame to
962    /// the currently fenced logical Child; it never accepts a caller ID here.
963    OwnedTreeRequest {
964        id: String,
965        #[serde(default, skip_serializing_if = "Option::is_none")]
966        cursor: Option<String>,
967    },
968    /// The Worker supplies only logical arguments and a transcript call id.
969    /// Caller identity is bound to the active Host drive, not this frame.
970    SubAgentRequest {
971        id: String,
972        tool_call_id: String,
973        args: serde_json::Value,
974    },
975    /// Emitted only after the worker's local SessionInbox transcript + cursor
976    /// checkpoint and admitted receipt are durable.
977    SessionMessageAdmitted {
978        confirmation: SessionMessageAdmissionConfirmation,
979    },
980    Terminal {
981        status: TerminalStatus,
982        /// Transport-owned current-Run completeness claim; absent on legacy or
983        /// ordinary lossy observation routes.
984        #[serde(default, skip_serializing_if = "Option::is_none")]
985        final_event_watermark: Option<ActorEventWatermark>,
986        #[serde(default, skip_serializing_if = "Option::is_none")]
987        result: Option<String>,
988        #[serde(default, skip_serializing_if = "Option::is_none")]
989        error: Option<String>,
990        /// Compatibility-only suspend payload. Current hosts reject Suspended
991        /// and never consume this field; canonical session checkpoints are the
992        /// transcript authority. Retained for rolling wire compatibility.
993        #[serde(default, skip_serializing_if = "Vec::is_empty")]
994        transcript: Vec<serde_json::Value>,
995    },
996}
997
998#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
999#[serde(rename_all = "snake_case")]
1000pub enum TerminalStatus {
1001    Completed,
1002    Error,
1003    Cancelled,
1004    /// The worker's loop suspended (it spawned its own sub-agents and is waiting
1005    /// on them). Non-terminal to the host: the completion coordinator resumes
1006    /// the worker (re-dispatch) once its children finish.
1007    Suspended,
1008}
1009
1010impl ParentFrame {
1011    pub fn to_text(&self) -> String {
1012        serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
1013    }
1014    pub fn from_text(s: &str) -> serde_json::Result<Self> {
1015        serde_json::from_str(s)
1016    }
1017}
1018
1019impl ChildFrame {
1020    pub fn to_text(&self) -> String {
1021        serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
1022    }
1023    pub fn from_text(s: &str) -> serde_json::Result<Self> {
1024        serde_json::from_str(s)
1025    }
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030    use super::*;
1031
1032    #[test]
1033    fn native_tool_ceiling_accepts_exact_shell_names_and_keeps_wire_bounds() {
1034        let parent = bamboo_domain::Session::new("native-parent", "model");
1035        let child = bamboo_domain::Session::new_child_of("native-child", &parent, "model", "role");
1036        let ceiling = NativeToolCeiling {
1037            version: 1,
1038            child_session_id: child.id.clone(),
1039            parent_session_id: parent.id,
1040            root_session_id: child.root_session_id,
1041            created_at: child.created_at,
1042            spawn_depth: child.spawn_depth,
1043            project_id: None,
1044            tools: NativeToolCeiling::NAMES
1045                .into_iter()
1046                .map(str::to_owned)
1047                .collect(),
1048        };
1049        ceiling.validate().unwrap();
1050        let raw = serde_json::to_value(&ceiling).unwrap();
1051        assert_eq!(raw["project_id"], serde_json::Value::Null);
1052        let restored: NativeToolCeiling = serde_json::from_value(raw.clone()).unwrap();
1053        assert_eq!(restored, ceiling);
1054        restored.validate().unwrap();
1055
1056        // Old frozen ceilings keep their exact legacy grant after decoding.
1057        let mut legacy = ceiling.clone();
1058        legacy.tools = vec!["Bash".into()];
1059        let restored: NativeToolCeiling =
1060            serde_json::from_slice(&serde_json::to_vec(&legacy).unwrap()).unwrap();
1061        restored.validate().unwrap();
1062        assert_eq!(restored.tools, ["Bash"]);
1063
1064        for tools in [
1065            vec!["default::exec_command".into()],
1066            vec!["Exec_Command".into()],
1067            vec!["exec_command".into(), "exec_command".into()],
1068            vec!["write_stdin".into(), "exec_command".into()],
1069            vec!["Bash".into(); NativeToolCeiling::NAMES.len() + 1],
1070        ] {
1071            let mut invalid = ceiling.clone();
1072            invalid.tools = tools;
1073            assert!(invalid.validate().is_err());
1074        }
1075        let mut oversized = ceiling.clone();
1076        oversized.child_session_id = "x".repeat(NativeToolCeiling::MAX_BYTES);
1077        assert!(oversized.validate().is_err());
1078        let mut missing_project = raw.clone();
1079        missing_project
1080            .as_object_mut()
1081            .unwrap()
1082            .remove("project_id");
1083        assert!(serde_json::from_value::<NativeToolCeiling>(missing_project).is_err());
1084        let mut unknown = raw;
1085        unknown["extra"] = true.into();
1086        assert!(serde_json::from_value::<NativeToolCeiling>(unknown).is_err());
1087    }
1088
1089    #[test]
1090    fn workflow_agent_usage_is_bound_to_current_activation_and_birth() {
1091        let mut child = bamboo_domain::Session::new("child", "model");
1092        child.agent_runtime_state = Some(bamboo_domain::AgentRuntimeState::new(""));
1093        let usage = WorkflowAgentUsage {
1094            kind: WorkflowAgentUsage::TYPE.into(),
1095            activation_run_id: "activation".into(),
1096            child_session_id: child.id.clone(),
1097            child_created_at: child.created_at,
1098            prompt_tokens: 24,
1099            completion_tokens: 12,
1100        };
1101        child.metadata.insert(
1102            WORKFLOW_USAGE_OBSERVATION_KEY.into(),
1103            serde_json::to_string(&usage).unwrap(),
1104        );
1105        assert_eq!(
1106            WorkflowAgentUsage::from_session(&child, "activation")
1107                .unwrap()
1108                .total_tokens(),
1109            36
1110        );
1111        assert!(WorkflowAgentUsage::from_session(&child, "next-activation").is_none());
1112        child.created_at += chrono::Duration::seconds(1);
1113        assert!(WorkflowAgentUsage::from_session(&child, "activation").is_none());
1114    }
1115
1116    #[test]
1117    fn local_tool_messages_are_closed_bounded_and_durable() {
1118        let raw = serde_json::to_value(bamboo_domain::Message::user("confirmed")).unwrap();
1119        let good = LocalToolMessages::Complete {
1120            version: 1,
1121            messages: vec![raw.clone()],
1122        };
1123        assert_eq!(
1124            serde_json::to_value(good.validate().unwrap()[0].clone()).unwrap(),
1125            raw
1126        );
1127        let event = serde_json::to_value(&good).unwrap();
1128        assert_eq!(ActorEventQos::classify(&event), ActorEventQos::Durable);
1129        for field in ["session", "permission", "version_extra"] {
1130            let mut changed = event.clone();
1131            changed[field] = serde_json::json!({});
1132            assert!(
1133                serde_json::from_value::<LocalToolMessages>(changed).is_err(),
1134                "{field}"
1135            );
1136        }
1137        for mutation in [
1138            "version",
1139            "missing",
1140            "unknown_message",
1141            "reasoning",
1142            "compressed",
1143            "duplicate",
1144            "bytes",
1145            "rows",
1146        ] {
1147            let mut changed = event.clone();
1148            match mutation {
1149                "version" => changed["version"] = 2.into(),
1150                "missing" => {
1151                    changed.as_object_mut().unwrap().remove("messages");
1152                }
1153                "unknown_message" => changed["messages"][0]["cursor"] = 1.into(),
1154                "reasoning" => changed["messages"][0]["reasoning"] = "private".into(),
1155                "compressed" => changed["messages"][0]["compression_level"] = 1.into(),
1156                "duplicate" => changed["messages"] = serde_json::json!([raw, raw]),
1157                "bytes" => {
1158                    changed["messages"][0]["content"] =
1159                        "\\\"".repeat(LocalToolMessages::MAX_BYTES).into()
1160                }
1161                "rows" => {
1162                    changed["messages"] =
1163                        serde_json::to_value(vec![raw.clone(); LocalToolMessages::MAX_MESSAGES + 1])
1164                            .unwrap()
1165                }
1166                _ => unreachable!(),
1167            }
1168            assert!(
1169                serde_json::from_value::<LocalToolMessages>(changed)
1170                    .map_or(true, |data| data.validate().is_err()),
1171                "{mutation}"
1172            );
1173        }
1174        assert!(LocalToolMessages::supports_tools(
1175            &["Read".into(), "Write".into()],
1176            false
1177        ));
1178        assert!(!LocalToolMessages::supports_tools(&["Glob".into()], true));
1179        assert!(!LocalToolMessages::supports_tools(&["Bash".into()], false));
1180    }
1181
1182    #[test]
1183    fn local_tool_messages_preserve_bounded_assistant_reasoning_without_provider_authority() {
1184        let mut assistant = bamboo_domain::Message::assistant("complete report", None);
1185        assistant.reasoning = Some("Complete ordinary text reasoning 🪷.".into());
1186        let raw = serde_json::to_value(&assistant).unwrap();
1187        let data = LocalToolMessages::Complete {
1188            version: 1,
1189            messages: vec![raw.clone()],
1190        };
1191        assert_eq!(
1192            serde_json::to_value(data.validate().unwrap()).unwrap(),
1193            serde_json::json!([raw])
1194        );
1195
1196        for mutation in ["signature", "tool", "user", "system", "compressed", "bytes"] {
1197            let mut message = assistant.clone();
1198            match mutation {
1199                "signature" => {
1200                    message.reasoning_signature = Some("opaque-provider-signature".into())
1201                }
1202                "tool" => message.role = bamboo_domain::Role::Tool,
1203                "user" => message.role = bamboo_domain::Role::User,
1204                "system" => message.role = bamboo_domain::Role::System,
1205                "compressed" => message.compressed = true,
1206                "bytes" => message.reasoning = Some("🪷".repeat(LocalToolMessages::MAX_BYTES)),
1207                _ => unreachable!(),
1208            }
1209            let changed = LocalToolMessages::Complete {
1210                version: 1,
1211                messages: vec![serde_json::to_value(&message).unwrap()],
1212            };
1213            assert!(changed.validate().is_err(), "{mutation}");
1214        }
1215    }
1216
1217    #[test]
1218    fn initial_release_schema_is_closed_and_exact() {
1219        let request = InitialInputReleaseRequest {
1220            version: 1,
1221            nonce: uuid::Uuid::new_v4().to_string(),
1222            child_id: "child".into(),
1223            parent_id: "parent".into(),
1224            root_id: "parent".into(),
1225            created_at: Utc::now(),
1226            spawn_depth: 1,
1227            project_id: None,
1228            envelope_id: "input".into(),
1229            generation: 1,
1230            activation_run_id: "run".into(),
1231            execution_epoch: 7,
1232        };
1233        let release = InitialInputRelease {
1234            request: request.clone(),
1235            expires_at: Utc::now() + chrono::Duration::seconds(1),
1236        };
1237        assert!(release.permits(&request, Utc::now()));
1238        for change in [
1239            "nonce",
1240            "generation",
1241            "execution_epoch",
1242            "child_id",
1243            "created_at",
1244            "project_id",
1245        ] {
1246            let mut value = serde_json::to_value(&request).unwrap();
1247            value[change] = match change {
1248                "generation" | "execution_epoch" => serde_json::json!(2),
1249                "created_at" => serde_json::json!("2020-01-01T00:00:00Z"),
1250                _ => serde_json::json!("foreign"),
1251            };
1252            if let Ok(changed) = serde_json::from_value(value) {
1253                assert!(!release.permits(&changed, Utc::now()), "{change}");
1254            }
1255        }
1256        assert!(!release.permits(&request, release.expires_at));
1257        let mut absent_project = serde_json::to_value(&request).unwrap();
1258        absent_project.as_object_mut().unwrap().remove("project_id");
1259        assert!(serde_json::from_value::<InitialInputReleaseRequest>(absent_project).is_err());
1260        let bytes = serde_json::to_string(&request).unwrap();
1261        let duplicate = bytes.replacen("{", "{\"nonce\":\"foreign\",", 1);
1262        assert!(serde_json::from_str::<InitialInputReleaseRequest>(&duplicate).is_err());
1263        let mut value = serde_json::to_value(InitialInputControl::Release { release }).unwrap();
1264        value["text"] = "cannot become steer".into();
1265        assert!(serde_json::from_value::<InitialInputControl>(value.clone()).is_err());
1266        value.as_object_mut().unwrap().remove("text");
1267        value["initial_input_control"] = "unknown".into();
1268        assert!(serde_json::from_value::<InitialInputControl>(value).is_err());
1269    }
1270
1271    #[test]
1272    fn parent_frames_round_trip() {
1273        for f in [
1274            ParentFrame::Run(RunSpec {
1275                assignment: "do x".into(),
1276                logical_session: None,
1277                project_id: None,
1278                reasoning_effort: None,
1279                permission_policy: None,
1280                messages: Vec::new(),
1281                activation_run_id: None,
1282                execution_epoch: 0,
1283                initial_session_messages: Vec::new(),
1284                secrets: Default::default(),
1285            }),
1286            ParentFrame::Cancel,
1287            ParentFrame::Message { text: "hi".into() },
1288        ] {
1289            assert_eq!(ParentFrame::from_text(&f.to_text()).unwrap(), f);
1290        }
1291    }
1292
1293    #[test]
1294    fn logical_creation_is_atomic_and_preserves_exact_birth() {
1295        let legacy = serde_json::json!({"session_id":"child", "parent_session_id":"parent", "root_session_id":"root"});
1296        let mut identity: LogicalSessionIdentity = serde_json::from_value(legacy.clone()).unwrap();
1297        assert!(identity.creation.is_none());
1298        assert_eq!(serde_json::to_value(&identity).unwrap(), legacy);
1299        identity.creation = Some(ChildCreationIdentity {
1300            created_at: "2026-09-26T01:02:03.123456789Z".parse().unwrap(),
1301            spawn_depth: 3,
1302        });
1303        let wire = serde_json::to_value(&identity).unwrap();
1304        assert_eq!(
1305            serde_json::from_value::<LogicalSessionIdentity>(wire.clone()).unwrap(),
1306            identity
1307        );
1308        for creation in [
1309            serde_json::json!({"created_at":"2026-09-26T01:02:03Z"}),
1310            serde_json::json!({"spawn_depth":3}),
1311            serde_json::json!({"created_at":"invalid", "spawn_depth":3}),
1312            serde_json::json!({"created_at":"2026-09-26T01:02:03Z", "spawn_depth":-1}),
1313            serde_json::json!({"created_at":"2026-09-26T01:02:03Z", "spawn_depth":3, "grant":true}),
1314        ] {
1315            let mut damaged = wire.clone();
1316            damaged["creation"] = creation;
1317            assert!(serde_json::from_value::<LogicalSessionIdentity>(damaged).is_err());
1318        }
1319    }
1320
1321    #[test]
1322    fn child_frames_round_trip() {
1323        let e = ChildFrame::Event {
1324            event: serde_json::json!({"type":"token","content":"hi"}),
1325        };
1326        assert_eq!(ChildFrame::from_text(&e.to_text()).unwrap(), e);
1327        let batch = ChildFrame::EventBatch {
1328            batch: ActorEventBatch {
1329                logical_session: Some(LogicalSessionIdentity {
1330                    creation: None,
1331                    session_id: "child".into(),
1332                    parent_session_id: Some("parent".into()),
1333                    root_session_id: "root".into(),
1334                }),
1335                activation_id: Some("run-7".into()),
1336                execution_epoch: 9,
1337                source_node_id: Some("node-a".into()),
1338                source_actor_id: Some("worker-2".into()),
1339                first_seq: 1,
1340                last_seq: 2,
1341                qos: ActorEventQos::Ephemeral,
1342                events: vec![
1343                    serde_json::json!({"type":"token","content":"a"}),
1344                    serde_json::json!({"type":"token","content":"b"}),
1345                ],
1346            },
1347        };
1348        assert_eq!(ChildFrame::from_text(&batch.to_text()).unwrap(), batch);
1349        let t = ChildFrame::Terminal {
1350            status: TerminalStatus::Completed,
1351            result: Some("done".into()),
1352            error: None,
1353            final_event_watermark: None,
1354            transcript: Vec::new(),
1355        };
1356        assert_eq!(ChildFrame::from_text(&t.to_text()).unwrap(), t);
1357
1358        // Suspend terminal carries the worker transcript.
1359        let s = ChildFrame::Terminal {
1360            status: TerminalStatus::Suspended,
1361            result: None,
1362            error: None,
1363            final_event_watermark: None,
1364            transcript: vec![serde_json::json!({"role":"assistant","content":"x"})],
1365        };
1366        assert_eq!(ChildFrame::from_text(&s.to_text()).unwrap(), s);
1367
1368        // Phase 2 approval request/reply round-trip over the per-child WS.
1369        let areq = ChildFrame::ApprovalRequest {
1370            id: "a1".into(),
1371            body: serde_json::json!({
1372                "tool_name": "Write",
1373                "permission_type": "WriteFile",
1374                "resource": "/tmp/x",
1375                "question": "approve?",
1376            }),
1377        };
1378        assert_eq!(ChildFrame::from_text(&areq.to_text()).unwrap(), areq);
1379        let areply = ParentFrame::ApprovalReply {
1380            id: "a1".into(),
1381            approved: true,
1382        };
1383        assert_eq!(ParentFrame::from_text(&areply.to_text()).unwrap(), areply);
1384    }
1385
1386    #[test]
1387    fn run_frame_tag_is_stable() {
1388        let f = ParentFrame::Run(RunSpec {
1389            assignment: "a".into(),
1390            logical_session: None,
1391            project_id: None,
1392            reasoning_effort: Some("high".into()),
1393            permission_policy: None,
1394            messages: Vec::new(),
1395            activation_run_id: None,
1396            execution_epoch: 0,
1397            initial_session_messages: Vec::new(),
1398            secrets: Default::default(),
1399        });
1400        let v: serde_json::Value = serde_json::from_str(&f.to_text()).unwrap();
1401        assert_eq!(v["kind"], "run");
1402        assert_eq!(v["assignment"], "a");
1403        assert!(v.get("secrets").is_none());
1404    }
1405
1406    #[test]
1407    fn run_secret_round_trips_but_debug_output_is_redacted() {
1408        let secret = SecretValue::new("bcx1_secret-570");
1409        assert_eq!(format!("{secret:?}"), "SecretValue([REDACTED])");
1410        assert!(!format!(
1411            "{:?}",
1412            RunSecrets {
1413                codex_provider_token: Some(secret.clone()),
1414            }
1415        )
1416        .contains("secret-570"));
1417
1418        let frame = ParentFrame::Run(RunSpec {
1419            assignment: "a".into(),
1420            logical_session: None,
1421            project_id: None,
1422            reasoning_effort: None,
1423            permission_policy: None,
1424            messages: Vec::new(),
1425            activation_run_id: None,
1426            execution_epoch: 0,
1427            initial_session_messages: Vec::new(),
1428            secrets: RunSecrets {
1429                codex_provider_token: Some(secret),
1430            },
1431        });
1432        let decoded = ParentFrame::from_text(&frame.to_text()).unwrap();
1433        assert_eq!(decoded, frame);
1434    }
1435
1436    #[test]
1437    fn permission_policy_context_round_trips_at_run_boundary() {
1438        let context = PermissionPolicyContext {
1439            revision: 9,
1440            requested_mode: "bypass".into(),
1441            effective_mode: "bypass".into(),
1442            bypass_permissions: true,
1443            auto_approve_permissions: false,
1444            session_id: "child-1".into(),
1445            workspace_path: Some("/workspace/project".into()),
1446            environment_lease: None,
1447            inherit_session_grants: false,
1448            policy: serde_json::json!({"enabled":true,"durable_rules":[]}),
1449        };
1450        let frame = ParentFrame::Run(RunSpec {
1451            assignment: "work".into(),
1452            logical_session: None,
1453            project_id: None,
1454            reasoning_effort: None,
1455            permission_policy: Some(context.clone()),
1456            messages: Vec::new(),
1457            activation_run_id: None,
1458            execution_epoch: 0,
1459            initial_session_messages: Vec::new(),
1460            secrets: Default::default(),
1461        });
1462        let decoded = ParentFrame::from_text(&frame.to_text()).unwrap();
1463        assert_eq!(decoded, frame);
1464        let ParentFrame::Run(run) = decoded else {
1465            panic!("expected run frame");
1466        };
1467        assert_eq!(run.permission_policy, Some(context));
1468    }
1469
1470    #[test]
1471    fn legacy_permission_policy_context_defaults_auto_to_false() {
1472        let frame = ParentFrame::from_text(
1473            r#"{"kind":"run","assignment":"work","permission_policy":{"revision":8,"bypass_permissions":true,"session_id":"legacy-child","inherit_session_grants":false,"policy":{}}}"#,
1474        )
1475        .unwrap();
1476        let ParentFrame::Run(run) = frame else {
1477            panic!("expected run frame");
1478        };
1479        let context = run.permission_policy.expect("permission policy");
1480        assert!(context.bypass_permissions);
1481        assert!(!context.auto_approve_permissions);
1482        assert_eq!(
1483            context.resolved_modes().unwrap(),
1484            (
1485                bamboo_domain::SessionPermissionMode::Bypass,
1486                bamboo_domain::PermissionMode::BypassPermissions,
1487            )
1488        );
1489    }
1490
1491    #[test]
1492    fn permission_policy_rejects_partial_typed_mode_pairs() {
1493        let context = PermissionPolicyContext {
1494            revision: 1,
1495            requested_mode: "auto".to_string(),
1496            effective_mode: String::new(),
1497            bypass_permissions: false,
1498            auto_approve_permissions: true,
1499            session_id: "partial-policy".to_string(),
1500            workspace_path: None,
1501            environment_lease: None,
1502            inherit_session_grants: false,
1503            policy: serde_json::json!({}),
1504        };
1505        assert!(context
1506            .resolved_modes()
1507            .unwrap_err()
1508            .contains("provided together"));
1509
1510        let effective_only = PermissionPolicyContext {
1511            requested_mode: String::new(),
1512            effective_mode: "auto".to_string(),
1513            ..context
1514        };
1515        assert!(effective_only
1516            .resolved_modes()
1517            .unwrap_err()
1518            .contains("provided together"));
1519    }
1520
1521    #[test]
1522    fn run_frame_without_messages_parses_backward_compat() {
1523        // An old-style frame (no `messages` field) must still parse.
1524        let parsed = ParentFrame::from_text(r#"{"kind":"run","assignment":"x"}"#).unwrap();
1525        match parsed {
1526            ParentFrame::Run(spec) => {
1527                assert_eq!(spec.assignment, "x");
1528                assert!(spec.messages.is_empty());
1529            }
1530            other => panic!("expected run frame, got {other:?}"),
1531        }
1532    }
1533
1534    #[test]
1535    fn run_frame_round_trips_typed_project_identity() {
1536        let frame = ParentFrame::Run(RunSpec {
1537            assignment: "work".into(),
1538            logical_session: None,
1539            project_id: Some(ProjectId::parse("project-1").unwrap()),
1540            reasoning_effort: None,
1541            permission_policy: None,
1542            messages: Vec::new(),
1543            activation_run_id: None,
1544            execution_epoch: 0,
1545            initial_session_messages: Vec::new(),
1546            secrets: Default::default(),
1547        });
1548
1549        let decoded = ParentFrame::from_text(&frame.to_text()).unwrap();
1550        assert_eq!(decoded, frame);
1551    }
1552
1553    #[test]
1554    fn run_frame_rejects_unsafe_project_identity() {
1555        let error =
1556            ParentFrame::from_text(r#"{"kind":"run","assignment":"x","project_id":"../other"}"#)
1557                .unwrap_err();
1558
1559        assert!(error.to_string().contains("invalid project id"));
1560    }
1561
1562    #[test]
1563    fn actor_event_batcher_sequences_and_separates_qos() {
1564        let spec = RunSpec {
1565            assignment: "work".into(),
1566            logical_session: Some(LogicalSessionIdentity {
1567                creation: None,
1568                session_id: "child".into(),
1569                parent_session_id: Some("parent".into()),
1570                root_session_id: "root".into(),
1571            }),
1572            project_id: None,
1573            reasoning_effort: None,
1574            permission_policy: None,
1575            messages: Vec::new(),
1576            activation_run_id: Some("activation-1".into()),
1577            execution_epoch: 4,
1578            initial_session_messages: Vec::new(),
1579            secrets: Default::default(),
1580        };
1581        let mut batcher =
1582            ActorEventBatcher::for_run(&spec, Some("node-a".into()), Some("actor-a".into()));
1583        assert!(batcher
1584            .push(serde_json::json!({"type":"token","content":"a"}))
1585            .is_empty());
1586        assert!(batcher
1587            .push(serde_json::json!({"type":"token","content":"b"}))
1588            .is_empty());
1589
1590        let ready = batcher.push(serde_json::json!({
1591            "type":"tool_start",
1592            "tool_call_id":"t1",
1593            "tool_name":"Read",
1594            "arguments":{}
1595        }));
1596        assert_eq!(ready.len(), 2);
1597        assert_eq!(ready[0].qos, ActorEventQos::Ephemeral);
1598        assert_eq!((ready[0].first_seq, ready[0].last_seq), (1, 2));
1599        assert_eq!(ready[1].qos, ActorEventQos::Durable);
1600        assert_eq!((ready[1].first_seq, ready[1].last_seq), (3, 3));
1601        assert!(ready.iter().all(|batch| batch.validate().is_ok()));
1602        assert_eq!(ready[1].activation_id.as_deref(), Some("activation-1"));
1603        assert_eq!(ready[1].execution_epoch, 4);
1604        assert!(!batcher.has_pending());
1605    }
1606
1607    #[test]
1608    fn actor_event_batch_rejects_qos_downgrade_and_bad_range() {
1609        let mut batch = ActorEventBatch {
1610            logical_session: None,
1611            activation_id: None,
1612            execution_epoch: 0,
1613            source_node_id: None,
1614            source_actor_id: None,
1615            first_seq: 1,
1616            last_seq: 1,
1617            qos: ActorEventQos::Ephemeral,
1618            events: vec![serde_json::json!({"type":"tool_start"})],
1619        };
1620        assert!(batch.validate().unwrap_err().contains("QoS"));
1621        batch.qos = ActorEventQos::Durable;
1622        batch.last_seq = 2;
1623        assert!(batch.validate().unwrap_err().contains("sequence"));
1624    }
1625
1626    #[test]
1627    fn strict_history_upgrade_preserves_bounded_token_coalescing() {
1628        let spec: RunSpec = serde_json::from_value(serde_json::json!({
1629            "assignment":"read", "execution_epoch":1
1630        }))
1631        .unwrap();
1632        let mut batcher = ActorEventBatcher::for_run(&spec, None, None).with_durable_events(true);
1633        for _ in 1..MAX_ACTOR_EVENT_BATCH_EVENTS {
1634            assert!(batcher
1635                .push(serde_json::json!({"type":"token","content":"x"}))
1636                .is_empty());
1637        }
1638        let batch = batcher
1639            .push(serde_json::json!({"type":"token","content":"x"}))
1640            .pop()
1641            .unwrap();
1642        assert_eq!(batch.qos, ActorEventQos::Durable);
1643        assert_eq!(batch.events.len(), MAX_ACTOR_EVENT_BATCH_EVENTS);
1644        assert_eq!(
1645            (batch.first_seq, batch.last_seq),
1646            (1, MAX_ACTOR_EVENT_BATCH_EVENTS as u64)
1647        );
1648        assert!(batch.validate().is_ok());
1649        assert!(!batcher.has_pending());
1650    }
1651
1652    #[test]
1653    fn task_item_progress_delta_is_never_put_on_a_lossy_lane() {
1654        let event = serde_json::json!({
1655            "type": "task_list_item_progress",
1656            "session_id": "child",
1657            "item_id": "task-1",
1658            "status": "in_progress",
1659            "tool_calls_count": 2,
1660            "version": 3
1661        });
1662        assert_eq!(ActorEventQos::classify(&event), ActorEventQos::Durable);
1663    }
1664}
1665
1666#[cfg(test)]
1667mod watermark_tests;