Skip to main content

bamboo_plugin_protocol/
tool_event_v1.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4use serde_json::{json, Map, Value};
5use thiserror::Error;
6
7use crate::projected_tool_event_v1::{
8    MAX_PROJECTED_TOOL_EVENT_CONTENT_BYTES, MAX_PROJECTED_TOOL_EVENT_DIFF_BYTES,
9    TOOL_EVENT_PATH_REDACTION_PERMISSION_NOT_GRANTED, TOOL_EVENT_PATH_REDACTION_SENSITIVE,
10    TOOL_EVENT_PATH_REDACTION_UNSAFE,
11};
12
13/// Wire schema carried by every [`ToolEventV1`].
14pub const TOOL_EVENT_V1_SCHEMA_VERSION: u16 = 1;
15/// Stable protocol-family name used by plugin event-sink manifests.
16pub const TOOL_EVENT_PROTOCOL_NAME: &str = "tool_event";
17/// Stable event-type identifier for a successful supported file mutation.
18pub const FILE_CHANGED_EVENT_TYPE_V1: &str = "file_changed";
19/// Stable manifest subscription identifier for [`FILE_CHANGED_EVENT_TYPE_V1`].
20pub const FILE_CHANGED_SUBSCRIPTION_ID_V1: &str = "tool.file_changed.v1";
21
22// Bounds apply to UTF-8 wire bytes, not Unicode scalar counts. Rejecting an
23// oversize event is preferable to silently changing an authority-bearing id or
24// path; projection/truncation policy belongs to the later policy layer.
25pub const MAX_TOOL_EVENT_SESSION_ID_BYTES: usize = 256;
26pub const MAX_TOOL_EVENT_ROOT_SESSION_ID_BYTES: usize = 256;
27pub const MAX_TOOL_EVENT_TYPE_BYTES: usize = 128;
28pub const MAX_TOOL_EVENT_SUBSCRIPTION_ID_BYTES: usize = 128;
29pub const MAX_TOOL_EVENT_TOOL_NAME_BYTES: usize = 128;
30pub const MAX_TOOL_EVENT_CALL_ID_BYTES: usize = 256;
31pub const MAX_TOOL_EVENT_PATH_BYTES: usize = 4096;
32pub const MAX_TOOL_EVENT_JSON_BYTES: usize = 16 * 1024;
33
34/// Forward-compatible event type. Unknown strings are retained verbatim.
35#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
36#[serde(transparent)]
37pub struct ToolEventTypeV1(String);
38
39impl ToolEventTypeV1 {
40    pub fn new(value: impl Into<String>) -> Self {
41        Self(value.into())
42    }
43
44    pub fn file_changed() -> Self {
45        Self(FILE_CHANGED_EVENT_TYPE_V1.to_string())
46    }
47
48    pub fn as_str(&self) -> &str {
49        &self.0
50    }
51
52    pub fn is_file_changed(&self) -> bool {
53        self.0 == FILE_CHANGED_EVENT_TYPE_V1
54    }
55}
56
57/// Forward-compatible subscription identifier used by plugin manifests.
58#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
59#[serde(transparent)]
60pub struct ToolEventSubscriptionId(String);
61
62impl ToolEventSubscriptionId {
63    pub fn new(value: impl Into<String>) -> Self {
64        Self(value.into())
65    }
66
67    pub fn file_changed_v1() -> Self {
68        Self(FILE_CHANGED_SUBSCRIPTION_ID_V1.to_string())
69    }
70
71    pub fn as_str(&self) -> &str {
72        &self.0
73    }
74
75    pub fn is_file_changed_v1(&self) -> bool {
76        self.0 == FILE_CHANGED_SUBSCRIPTION_ID_V1
77    }
78}
79
80/// Stable identity attached to every tool event.
81#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
82pub struct ToolEventContextV1 {
83    pub session_id: String,
84    /// Authoritative root-session identity for the executing session tree.
85    pub root_session_id: String,
86    /// Stable canonical tool name (for example `Edit`, even for `apply_patch`).
87    pub tool_name: String,
88    /// Original model-provided tool-call identifier.
89    pub tool_call_id: String,
90    /// Additive context fields added by a future compatible producer.
91    #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
92    pub extensions: BTreeMap<String, Value>,
93}
94
95impl ToolEventContextV1 {
96    /// Validate borrowed fields before allocating owned wire DTO strings.
97    pub fn bounded_from(
98        session_id: &str,
99        root_session_id: &str,
100        tool_name: &str,
101        tool_call_id: &str,
102    ) -> Result<Self, ToolEventBuildError> {
103        validate_required(
104            "context.session_id",
105            session_id,
106            MAX_TOOL_EVENT_SESSION_ID_BYTES,
107        )?;
108        validate_required(
109            "context.root_session_id",
110            root_session_id,
111            MAX_TOOL_EVENT_ROOT_SESSION_ID_BYTES,
112        )?;
113        validate_required(
114            "context.tool_name",
115            tool_name,
116            MAX_TOOL_EVENT_TOOL_NAME_BYTES,
117        )?;
118        validate_required(
119            "context.tool_call_id",
120            tool_call_id,
121            MAX_TOOL_EVENT_CALL_ID_BYTES,
122        )?;
123        Ok(Self {
124            session_id: session_id.to_string(),
125            root_session_id: root_session_id.to_string(),
126            tool_name: tool_name.to_string(),
127            tool_call_id: tool_call_id.to_string(),
128            extensions: BTreeMap::new(),
129        })
130    }
131
132    pub fn bounded(
133        session_id: impl Into<String>,
134        root_session_id: impl Into<String>,
135        tool_name: impl Into<String>,
136        tool_call_id: impl Into<String>,
137    ) -> Result<Self, ToolEventBuildError> {
138        let context = Self {
139            session_id: session_id.into(),
140            root_session_id: root_session_id.into(),
141            tool_name: tool_name.into(),
142            tool_call_id: tool_call_id.into(),
143            extensions: BTreeMap::new(),
144        };
145        context.validate_bounds()?;
146        Ok(context)
147    }
148
149    fn validate_bounds(&self) -> Result<(), ToolEventBuildError> {
150        validate_extension_keys(
151            "context",
152            &self.extensions,
153            &["session_id", "root_session_id", "tool_name", "tool_call_id"],
154        )?;
155        validate_required(
156            "context.session_id",
157            &self.session_id,
158            MAX_TOOL_EVENT_SESSION_ID_BYTES,
159        )?;
160        validate_required(
161            "context.root_session_id",
162            &self.root_session_id,
163            MAX_TOOL_EVENT_ROOT_SESSION_ID_BYTES,
164        )?;
165        validate_required(
166            "context.tool_name",
167            &self.tool_name,
168            MAX_TOOL_EVENT_TOOL_NAME_BYTES,
169        )?;
170        validate_required(
171            "context.tool_call_id",
172            &self.tool_call_id,
173            MAX_TOOL_EVENT_CALL_ID_BYTES,
174        )
175    }
176}
177
178/// Stable payload for a successful `file_changed` event.
179///
180/// It intentionally contains no content, diff, permission, or redaction data.
181/// Those projections are owned by later policy/routing layers.
182#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
183pub struct FileChangedV1 {
184    pub path: String,
185    /// Additive payload fields from a future compatible producer.
186    #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
187    pub extensions: BTreeMap<String, Value>,
188}
189
190impl FileChangedV1 {
191    /// Validate a borrowed path before allocating its owned wire representation.
192    pub fn bounded_from(path: &str) -> Result<Self, ToolEventBuildError> {
193        validate_required("data.path", path, MAX_TOOL_EVENT_PATH_BYTES)?;
194        Ok(Self {
195            path: path.to_string(),
196            extensions: BTreeMap::new(),
197        })
198    }
199
200    pub fn bounded(path: impl Into<String>) -> Result<Self, ToolEventBuildError> {
201        let data = Self {
202            path: path.into(),
203            extensions: BTreeMap::new(),
204        };
205        data.validate_bounds()?;
206        Ok(data)
207    }
208
209    fn validate_bounds(&self) -> Result<(), ToolEventBuildError> {
210        validate_extension_keys("data", &self.extensions, &["path"])?;
211        validate_required("data.path", &self.path, MAX_TOOL_EVENT_PATH_BYTES)
212    }
213}
214
215/// Versioned, forward-compatible event envelope delivered to plugin sinks.
216///
217/// `event_type` and `subscription_id` are open string types and `data` is kept
218/// as JSON on the envelope, so an older host can deserialize and forward an
219/// unknown compatible variant without discarding its payload. Known consumers
220/// use [`ToolEventV1::file_changed_data`] for the stable typed DTO.
221#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
222pub struct ToolEventV1 {
223    pub schema_version: u16,
224    pub event_type: ToolEventTypeV1,
225    pub subscription_id: ToolEventSubscriptionId,
226    pub context: ToolEventContextV1,
227    pub data: Value,
228    /// Additive envelope fields from a future compatible producer.
229    #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
230    pub extensions: BTreeMap<String, Value>,
231}
232
233impl ToolEventV1 {
234    pub fn file_changed(
235        context: ToolEventContextV1,
236        data: FileChangedV1,
237    ) -> Result<Self, ToolEventBuildError> {
238        // Validate borrowed/owned fields before moving them into the raw
239        // producer envelope. Build the Value directly: host projection must
240        // get the first and only serialization opportunity for fields that a
241        // plugin may not be authorized to observe.
242        context.validate_bounds()?;
243        data.validate_bounds()?;
244        let FileChangedV1 { path, extensions } = data;
245        let mut data = Map::new();
246        data.insert("path".to_string(), Value::String(path));
247        data.extend(extensions);
248        let event = Self {
249            schema_version: TOOL_EVENT_V1_SCHEMA_VERSION,
250            event_type: ToolEventTypeV1::file_changed(),
251            subscription_id: ToolEventSubscriptionId::file_changed_v1(),
252            context,
253            data: Value::Object(data),
254            extensions: BTreeMap::new(),
255        };
256        event.validate_projection_input_bounds()?;
257        Ok(event)
258    }
259
260    /// Decode the stable payload only when this is the known v1 variant.
261    /// Unknown variants remain available through the public `data` field.
262    pub fn file_changed_data(&self) -> Option<Result<FileChangedV1, serde_json::Error>> {
263        (self.schema_version == TOOL_EVENT_V1_SCHEMA_VERSION
264            && self.event_type.is_file_changed()
265            && self.subscription_id.is_file_changed_v1())
266        .then(|| serde_json::from_value(self.data.clone()))
267    }
268
269    /// Revalidate an event before crossing a bounded publisher boundary.
270    pub fn validate_bounds(&self) -> Result<(), ToolEventBuildError> {
271        self.validate_projection_input_bounds()?;
272
273        // The complete protocol boundary retains an exact serialized-byte
274        // check. Host policy routers may instead call the field-only method
275        // below, discard unknown/unauthorized extensions without cloning or
276        // serializing them, and size the resulting projection exactly once.
277        let actual = serde_json::to_vec(self)
278            .map_err(|error| ToolEventBuildError::Serialization(error.to_string()))?
279            .len();
280        if actual > MAX_TOOL_EVENT_JSON_BYTES {
281            return Err(ToolEventBuildError::EventTooLarge {
282                actual,
283                max: MAX_TOOL_EVENT_JSON_BYTES,
284            });
285        }
286        Ok(())
287    }
288
289    /// Validate authority-bearing fields and the known payload shape without
290    /// serializing or deep-cloning producer extensions.
291    ///
292    /// This is the input half of a host projection boundary: callers MUST
293    /// construct a new deny-by-default DTO and apply an exact total-byte bound
294    /// to that projection before queueing it. It is intentionally not a
295    /// replacement for [`Self::validate_bounds`] at general wire boundaries.
296    pub fn validate_projection_input_bounds(&self) -> Result<(), ToolEventBuildError> {
297        if self.schema_version != TOOL_EVENT_V1_SCHEMA_VERSION {
298            return Err(ToolEventBuildError::UnsupportedSchemaVersion {
299                actual: self.schema_version,
300                supported: TOOL_EVENT_V1_SCHEMA_VERSION,
301            });
302        }
303        validate_extension_keys(
304            "envelope",
305            &self.extensions,
306            &[
307                "schema_version",
308                "event_type",
309                "subscription_id",
310                "context",
311                "data",
312            ],
313        )?;
314        self.context.validate_bounds()?;
315        validate_required(
316            "event_type",
317            self.event_type.as_str(),
318            MAX_TOOL_EVENT_TYPE_BYTES,
319        )?;
320        validate_required(
321            "subscription_id",
322            self.subscription_id.as_str(),
323            MAX_TOOL_EVENT_SUBSCRIPTION_ID_BYTES,
324        )?;
325
326        if !self.data.is_object() {
327            return Err(ToolEventBuildError::DataMustBeObject);
328        }
329
330        let event_known = self.event_type.is_file_changed();
331        let subscription_known = self.subscription_id.is_file_changed_v1();
332        if event_known != subscription_known {
333            return Err(ToolEventBuildError::KnownVariantMismatch);
334        }
335        if event_known {
336            let path = self
337                .data
338                .as_object()
339                .and_then(|data| data.get("path"))
340                .and_then(Value::as_str)
341                .ok_or_else(|| {
342                    ToolEventBuildError::InvalidKnownPayload(
343                        "file_changed data.path must be a string".to_string(),
344                    )
345                })?;
346            validate_required("data.path", path, MAX_TOOL_EVENT_PATH_BYTES)?;
347        }
348        Ok(())
349    }
350}
351
352fn validate_required(
353    field: &'static str,
354    value: &str,
355    max: usize,
356) -> Result<(), ToolEventBuildError> {
357    if value.trim().is_empty() {
358        return Err(ToolEventBuildError::EmptyField { field });
359    }
360    let actual = value.len();
361    if actual > max {
362        return Err(ToolEventBuildError::FieldTooLarge { field, actual, max });
363    }
364    Ok(())
365}
366
367fn validate_extension_keys(
368    location: &'static str,
369    extensions: &BTreeMap<String, Value>,
370    reserved: &[&str],
371) -> Result<(), ToolEventBuildError> {
372    if let Some(key) = reserved.iter().find(|key| extensions.contains_key(**key)) {
373        return Err(ToolEventBuildError::ReservedExtensionKey {
374            location,
375            key: (*key).to_string(),
376        });
377    }
378    Ok(())
379}
380
381#[derive(Clone, Debug, Error, PartialEq, Eq)]
382pub enum ToolEventBuildError {
383    #[error("tool event field `{field}` must not be empty")]
384    EmptyField { field: &'static str },
385    #[error("tool event field `{field}` is {actual} bytes; maximum is {max}")]
386    FieldTooLarge {
387        field: &'static str,
388        actual: usize,
389        max: usize,
390    },
391    #[error("tool event is {actual} bytes; maximum is {max}")]
392    EventTooLarge { actual: usize, max: usize },
393    #[error("tool event data must be a JSON object")]
394    DataMustBeObject,
395    #[error("unsupported tool event schema version {actual}; supported version is {supported}")]
396    UnsupportedSchemaVersion { actual: u16, supported: u16 },
397    #[error("file_changed event type and subscription id must be paired")]
398    KnownVariantMismatch,
399    #[error("invalid known tool event payload: {0}")]
400    InvalidKnownPayload(String),
401    #[error("tool event {location} extension conflicts with reserved key `{key}`")]
402    ReservedExtensionKey { location: &'static str, key: String },
403    #[error("failed to serialize tool event: {0}")]
404    Serialization(String),
405}
406
407/// Public JSON Schema for the v1 wire envelope.
408///
409/// The discriminants are deliberately open strings. Known `file_changed`
410/// payloads are typed by the `if/then` branch while unknown compatible variants
411/// retain arbitrary object data and additive fields.
412pub fn tool_event_v1_schema() -> Value {
413    json!({
414        "$schema": "https://json-schema.org/draft/2020-12/schema",
415        "$id": "https://bamboo.dev/schemas/plugin/tool-event-v1.schema.json",
416        "title": "ToolEventV1",
417        "type": "object",
418        "x-bamboo-maxJsonBytes": MAX_TOOL_EVENT_JSON_BYTES,
419        "required": ["schema_version", "event_type", "subscription_id", "context", "data"],
420        "properties": {
421            "schema_version": { "const": TOOL_EVENT_V1_SCHEMA_VERSION },
422            "event_type": { "type": "string", "minLength": 1, "x-bamboo-maxUtf8Bytes": MAX_TOOL_EVENT_TYPE_BYTES },
423            "subscription_id": { "type": "string", "minLength": 1, "x-bamboo-maxUtf8Bytes": MAX_TOOL_EVENT_SUBSCRIPTION_ID_BYTES },
424            "context": {
425                "type": "object",
426                "required": ["session_id", "root_session_id", "tool_call_id"],
427                "properties": {
428                    "session_id": { "type": "string", "minLength": 1, "x-bamboo-maxUtf8Bytes": MAX_TOOL_EVENT_SESSION_ID_BYTES },
429                    "root_session_id": { "type": "string", "minLength": 1, "x-bamboo-maxUtf8Bytes": MAX_TOOL_EVENT_ROOT_SESSION_ID_BYTES },
430                    "tool_name": { "type": "string", "minLength": 1, "x-bamboo-maxUtf8Bytes": MAX_TOOL_EVENT_TOOL_NAME_BYTES },
431                    "tool_call_id": { "type": "string", "minLength": 1, "x-bamboo-maxUtf8Bytes": MAX_TOOL_EVENT_CALL_ID_BYTES }
432                },
433                "additionalProperties": true
434            },
435            "data": { "type": "object" },
436            "observation_policy_generation": { "type": "integer", "minimum": 1 }
437        },
438        "allOf": [{
439            "if": {
440                "properties": {
441                    "event_type": { "const": FILE_CHANGED_EVENT_TYPE_V1 }
442                },
443                "required": ["event_type"]
444            },
445            "then": {
446                "properties": {
447                    "subscription_id": { "const": FILE_CHANGED_SUBSCRIPTION_ID_V1 },
448                    "data": {
449                        "type": "object",
450                        "properties": {
451                            "path": { "type": "string", "minLength": 1, "x-bamboo-maxUtf8Bytes": MAX_TOOL_EVENT_PATH_BYTES },
452                            "path_redaction_reason": {
453                                "type": "string",
454                                "enum": [
455                                    TOOL_EVENT_PATH_REDACTION_PERMISSION_NOT_GRANTED,
456                                    TOOL_EVENT_PATH_REDACTION_SENSITIVE,
457                                    TOOL_EVENT_PATH_REDACTION_UNSAFE
458                                ]
459                            },
460                            "diff": { "type": "string", "x-bamboo-maxUtf8Bytes": MAX_PROJECTED_TOOL_EVENT_DIFF_BYTES },
461                            "diff_truncated": { "type": "boolean" },
462                            "content": { "type": "string", "x-bamboo-maxUtf8Bytes": MAX_PROJECTED_TOOL_EVENT_CONTENT_BYTES },
463                            "content_truncated": { "type": "boolean" }
464                        },
465                        "oneOf": [{
466                            "required": ["path"],
467                            "not": { "required": ["path_redaction_reason"] }
468                        }, {
469                            "required": ["path_redaction_reason"],
470                            "not": { "required": ["path"] }
471                        }],
472                        "allOf": [{
473                            "if": {
474                                "anyOf": [
475                                    { "required": ["diff"] },
476                                    { "required": ["diff_truncated"] },
477                                    { "required": ["content"] },
478                                    { "required": ["content_truncated"] }
479                                ]
480                            },
481                            "then": { "required": ["path"] }
482                        }, {
483                            "if": { "required": ["diff_truncated"] },
484                            "then": { "required": ["diff"] }
485                        }, {
486                            "if": { "required": ["content_truncated"] },
487                            "then": { "required": ["content"] }
488                        }],
489                        "additionalProperties": true
490                    }
491                }
492            }
493        }, {
494            "if": {
495                "properties": {
496                    "subscription_id": { "const": FILE_CHANGED_SUBSCRIPTION_ID_V1 }
497                },
498                "required": ["subscription_id"]
499            },
500            "then": {
501                "properties": {
502                    "event_type": { "const": FILE_CHANGED_EVENT_TYPE_V1 }
503                }
504            }
505        }],
506        "additionalProperties": true
507    })
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513
514    fn fixture_event() -> ToolEventV1 {
515        ToolEventV1::file_changed(
516            ToolEventContextV1::bounded("session-1", "root-session-1", "Write", "call-1").unwrap(),
517            FileChangedV1::bounded("/workspace/zenith/src/lib.rs").unwrap(),
518        )
519        .unwrap()
520    }
521
522    #[test]
523    fn file_changed_wire_json_matches_golden() {
524        let json = serde_json::to_string(&fixture_event()).unwrap();
525        assert_eq!(
526            json,
527            include_str!("../tests/golden/tool_event_v1.file_changed.json").trim()
528        );
529    }
530
531    #[test]
532    fn schema_matches_golden() {
533        let expected: Value =
534            serde_json::from_str(include_str!("../tests/golden/tool_event_v1.schema.json"))
535                .unwrap();
536        assert_eq!(tool_event_v1_schema(), expected);
537    }
538
539    #[test]
540    fn golden_event_validates_against_public_schema() {
541        let schema = tool_event_v1_schema();
542        let validator = jsonschema::validator_for(&schema).unwrap();
543        let fixture: Value = serde_json::from_str(include_str!(
544            "../tests/golden/tool_event_v1.file_changed.json"
545        ))
546        .unwrap();
547        assert!(validator.validate(&fixture).is_ok());
548
549        let mut mismatched = fixture;
550        mismatched["subscription_id"] = json!("tool.future.v1");
551        assert!(validator.validate(&mismatched).is_err());
552    }
553
554    #[test]
555    fn metadata_only_host_projection_round_trips_and_validates_against_public_schema() {
556        let projection = crate::ProjectedToolEventV1::file_changed(
557            crate::ProjectedToolEventContextV1 {
558                session_id: "session-1".to_string(),
559                root_session_id: "root-session-1".to_string(),
560                tool_name: None,
561                tool_call_id: "call-1".to_string(),
562            },
563            crate::ProjectedFileChangedV1 {
564                path_redaction_reason: Some(
565                    crate::TOOL_EVENT_PATH_REDACTION_PERMISSION_NOT_GRANTED.to_string(),
566                ),
567                ..crate::ProjectedFileChangedV1::default()
568            },
569            3,
570        );
571        let value = serde_json::to_value(&projection).unwrap();
572        assert!(value["context"].get("tool_name").is_none());
573        assert!(value["data"].get("path").is_none());
574        let validator = jsonschema::validator_for(&tool_event_v1_schema()).unwrap();
575        assert!(validator.validate(&value).is_ok());
576        assert_eq!(
577            serde_json::from_value::<crate::ProjectedToolEventV1>(value.clone()).unwrap(),
578            projection
579        );
580
581        let mut path_and_reason = value.clone();
582        path_and_reason["data"]["path"] = json!("/workspace/file.rs");
583        assert!(validator.validate(&path_and_reason).is_err());
584        let mut redacted_payload = value.clone();
585        redacted_payload["data"]["content"] = json!("must-not-be-accepted");
586        assert!(validator.validate(&redacted_payload).is_err());
587        let mut empty_data = value;
588        empty_data["data"] = json!({});
589        assert!(validator.validate(&empty_data).is_err());
590    }
591
592    #[test]
593    fn unknown_compatible_variant_and_extensions_round_trip() {
594        let raw = json!({
595            "schema_version": 1,
596            "event_type": "future_event",
597            "subscription_id": "tool.future_event.v1",
598            "context": {
599                "session_id": "session-1",
600                "root_session_id": "root-session-1",
601                "tool_name": "FutureTool",
602                "tool_call_id": "call-future",
603                "trace_hint": "kept"
604            },
605            "data": { "future": [1, 2, 3] },
606            "producer_hint": { "also": "kept" }
607        });
608        let decoded: ToolEventV1 = serde_json::from_value(raw.clone()).unwrap();
609
610        assert_eq!(decoded.event_type.as_str(), "future_event");
611        assert!(decoded.file_changed_data().is_none());
612        assert_eq!(serde_json::to_value(decoded).unwrap(), raw);
613    }
614
615    #[test]
616    fn required_and_size_bounds_are_explicit() {
617        assert_eq!(
618            ToolEventContextV1::bounded("", "root-session", "Write", "call").unwrap_err(),
619            ToolEventBuildError::EmptyField {
620                field: "context.session_id"
621            }
622        );
623        assert!(matches!(
624            FileChangedV1::bounded("x".repeat(MAX_TOOL_EVENT_PATH_BYTES + 1)),
625            Err(ToolEventBuildError::FieldTooLarge {
626                field: "data.path",
627                ..
628            })
629        ));
630    }
631
632    #[test]
633    fn string_bounds_count_exact_utf8_bytes() {
634        let exact = "é".repeat(MAX_TOOL_EVENT_PATH_BYTES / "é".len());
635        assert_eq!(exact.len(), MAX_TOOL_EVENT_PATH_BYTES);
636        assert!(FileChangedV1::bounded(exact).is_ok());
637
638        let over = "é".repeat(MAX_TOOL_EVENT_PATH_BYTES / "é".len() + 1);
639        assert_eq!(
640            FileChangedV1::bounded(over),
641            Err(ToolEventBuildError::FieldTooLarge {
642                field: "data.path",
643                actual: MAX_TOOL_EVENT_PATH_BYTES + "é".len(),
644                max: MAX_TOOL_EVENT_PATH_BYTES,
645            })
646        );
647
648        let schema = tool_event_v1_schema();
649        assert_eq!(schema["properties"]["data"]["type"], json!("object"));
650        assert_eq!(
651            schema["allOf"][0]["then"]["properties"]["data"]["properties"]["path"]
652                ["x-bamboo-maxUtf8Bytes"],
653            json!(MAX_TOOL_EVENT_PATH_BYTES)
654        );
655        assert!(
656            schema["allOf"][0]["then"]["properties"]["data"]["properties"]["path"]
657                .get("maxLength")
658                .is_none()
659        );
660    }
661
662    #[test]
663    fn total_wire_json_bound_is_enforced_after_field_bounds() {
664        let mut event = fixture_event();
665        event.extensions.insert(
666            "future_payload".to_string(),
667            json!("x".repeat(MAX_TOOL_EVENT_JSON_BYTES)),
668        );
669
670        assert!(matches!(
671            event.validate_bounds(),
672            Err(ToolEventBuildError::EventTooLarge {
673                actual,
674                max: MAX_TOOL_EVENT_JSON_BYTES,
675            }) if actual > MAX_TOOL_EVENT_JSON_BYTES
676        ));
677    }
678
679    #[test]
680    fn raw_constructor_does_not_serialize_unknown_payload_before_projection() {
681        let mut data = FileChangedV1::bounded("/workspace/file.rs").unwrap();
682        data.extensions.insert(
683            "future_secret".to_string(),
684            json!("secret".repeat(MAX_TOOL_EVENT_JSON_BYTES)),
685        );
686
687        let event = ToolEventV1::file_changed(
688            ToolEventContextV1::bounded("session", "root", "Write", "call").unwrap(),
689            data,
690        )
691        .expect("field-valid raw input is assembled without whole-event serialization");
692        assert!(matches!(
693            event.validate_bounds(),
694            Err(ToolEventBuildError::EventTooLarge {
695                actual,
696                max: MAX_TOOL_EVENT_JSON_BYTES,
697            }) if actual > MAX_TOOL_EVENT_JSON_BYTES
698        ));
699    }
700
701    #[test]
702    fn all_variants_require_object_data_and_known_identifiers_stay_paired() {
703        let mut event = fixture_event();
704        event.data = json!("not-an-object");
705        assert_eq!(
706            event.validate_bounds(),
707            Err(ToolEventBuildError::DataMustBeObject)
708        );
709
710        let mut event = fixture_event();
711        event.subscription_id = ToolEventSubscriptionId::new("tool.future.v1");
712        assert_eq!(
713            event.validate_bounds(),
714            Err(ToolEventBuildError::KnownVariantMismatch)
715        );
716
717        let mut event = fixture_event();
718        event.event_type = ToolEventTypeV1::new("future_event");
719        assert_eq!(
720            event.validate_bounds(),
721            Err(ToolEventBuildError::KnownVariantMismatch)
722        );
723
724        let mut event = fixture_event();
725        event.schema_version = TOOL_EVENT_V1_SCHEMA_VERSION + 1;
726        assert_eq!(
727            event.validate_bounds(),
728            Err(ToolEventBuildError::UnsupportedSchemaVersion {
729                actual: TOOL_EVENT_V1_SCHEMA_VERSION + 1,
730                supported: TOOL_EVENT_V1_SCHEMA_VERSION,
731            })
732        );
733    }
734
735    #[test]
736    fn flattened_extensions_cannot_shadow_reserved_wire_fields() {
737        let mut context = fixture_event().context;
738        context
739            .extensions
740            .insert("session_id".to_string(), json!("spoofed-session"));
741        assert_eq!(
742            context.validate_bounds(),
743            Err(ToolEventBuildError::ReservedExtensionKey {
744                location: "context",
745                key: "session_id".to_string(),
746            })
747        );
748
749        let mut data = FileChangedV1::bounded("/bounded/file.txt").unwrap();
750        data.extensions
751            .insert("path".to_string(), json!("/spoofed/file.txt"));
752        assert_eq!(
753            data.validate_bounds(),
754            Err(ToolEventBuildError::ReservedExtensionKey {
755                location: "data",
756                key: "path".to_string(),
757            })
758        );
759
760        let mut event = fixture_event();
761        event
762            .extensions
763            .insert("schema_version".to_string(), json!(999));
764        assert_eq!(
765            event.validate_bounds(),
766            Err(ToolEventBuildError::ReservedExtensionKey {
767                location: "envelope",
768                key: "schema_version".to_string(),
769            })
770        );
771    }
772}