Skip to main content

blazingly_contract/
lib.rs

1#![forbid(unsafe_code)]
2#![cfg_attr(not(feature = "std"), no_std)]
3#![doc = include_str!("../README.md")]
4
5extern crate alloc;
6
7use alloc::boxed::Box;
8use alloc::format;
9use alloc::string::{String, ToString};
10use alloc::vec;
11use alloc::vec::Vec;
12use core::fmt;
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15
16/// The current canonical contract encoding version.
17pub const CURRENT_CONTRACT_FORMAT_VERSION: ContractFormatVersion = ContractFormatVersion::new(1, 3);
18
19/// Version of the canonical Blazingly contract format.
20///
21/// Major versions may change canonical encoding or compatibility semantics.
22/// Minor versions may add backward-compatible metadata.
23#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
24pub struct ContractFormatVersion {
25    pub major: u16,
26    pub minor: u16,
27}
28
29impl ContractFormatVersion {
30    #[must_use]
31    pub const fn new(major: u16, minor: u16) -> Self {
32        Self { major, minor }
33    }
34}
35
36impl Default for ContractFormatVersion {
37    fn default() -> Self {
38        CURRENT_CONTRACT_FORMAT_VERSION
39    }
40}
41
42impl fmt::Display for ContractFormatVersion {
43    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
44        write!(formatter, "{}.{}", self.major, self.minor)
45    }
46}
47
48/// Stable SHA-256 identity of one canonical operation contract.
49#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
50pub struct ContractFingerprint {
51    pub format_version: ContractFormatVersion,
52    digest: [u8; 32],
53}
54
55impl ContractFingerprint {
56    #[must_use]
57    pub const fn new(format_version: ContractFormatVersion, digest: [u8; 32]) -> Self {
58        Self {
59            format_version,
60            digest,
61        }
62    }
63
64    #[must_use]
65    pub const fn as_bytes(&self) -> &[u8; 32] {
66        &self.digest
67    }
68}
69
70impl fmt::Display for ContractFingerprint {
71    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
72        write!(
73            formatter,
74            "blazingly-contract-v{}-sha256:",
75            self.format_version
76        )?;
77        for byte in self.digest {
78            write!(formatter, "{byte:02x}")?;
79        }
80        Ok(())
81    }
82}
83
84/// Compatibility impact of one semantic contract change.
85#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
86#[serde(rename_all = "snake_case")]
87pub enum CompatibilityImpact {
88    Breaking,
89    NonBreaking,
90    Metadata,
91}
92
93/// One deterministic compatibility finding.
94#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
95pub struct CompatibilityChange {
96    pub impact: CompatibilityImpact,
97    pub path: String,
98    pub code: String,
99    pub message: String,
100}
101
102impl CompatibilityChange {
103    #[must_use]
104    pub fn new(
105        impact: CompatibilityImpact,
106        path: impl Into<String>,
107        code: impl Into<String>,
108        message: impl Into<String>,
109    ) -> Self {
110        Self {
111            impact,
112            path: path.into(),
113            code: code.into(),
114            message: message.into(),
115        }
116    }
117}
118
119/// Semantic compatibility report between two versions of one operation.
120#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
121pub struct CompatibilityReport {
122    pub previous: ContractFingerprint,
123    pub current: ContractFingerprint,
124    pub changes: Vec<CompatibilityChange>,
125}
126
127impl CompatibilityReport {
128    #[must_use]
129    pub fn is_backward_compatible(&self) -> bool {
130        !self
131            .changes
132            .iter()
133            .any(|change| change.impact == CompatibilityImpact::Breaking)
134    }
135
136    pub fn breaking_changes(&self) -> impl Iterator<Item = &CompatibilityChange> {
137        self.changes
138            .iter()
139            .filter(|change| change.impact == CompatibilityImpact::Breaking)
140    }
141}
142
143/// Entry point for semantic contract comparison.
144#[derive(Clone, Copy, Debug, Default)]
145pub struct Compatibility;
146
147/// A stable, human-readable operation identity such as `users.create`.
148#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
149#[serde(transparent)]
150pub struct OperationId(String);
151
152impl OperationId {
153    /// Creates an operation identity.
154    ///
155    /// Identities are deliberately conservative because they will later be
156    /// used in generated clients, contract snapshots, and internal service
157    /// calls.
158    ///
159    /// # Errors
160    ///
161    /// Returns [`InvalidOperationId`] when the identity is empty or contains
162    /// characters outside the stable operation-id alphabet.
163    pub fn new(value: impl Into<String>) -> Result<Self, InvalidOperationId> {
164        let value = value.into();
165        let valid = !value.is_empty()
166            && value
167                .bytes()
168                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_'));
169
170        if valid {
171            Ok(Self(value))
172        } else {
173            Err(InvalidOperationId { value })
174        }
175    }
176
177    #[must_use]
178    pub fn as_str(&self) -> &str {
179        &self.0
180    }
181}
182
183impl fmt::Display for OperationId {
184    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
185        formatter.write_str(self.as_str())
186    }
187}
188
189/// An invalid operation identity.
190#[derive(Clone, Debug, Eq, PartialEq)]
191pub struct InvalidOperationId {
192    value: String,
193}
194
195impl InvalidOperationId {
196    #[must_use]
197    pub fn value(&self) -> &str {
198        &self.value
199    }
200}
201
202impl fmt::Display for InvalidOperationId {
203    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
204        write!(
205            formatter,
206            "operation id {:?} must contain only ASCII letters, digits, '.', '-' or '_'",
207            self.value
208        )
209    }
210}
211
212#[cfg(feature = "std")]
213impl std::error::Error for InvalidOperationId {}
214
215/// Transport-independent JSON shape.
216#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
217#[serde(rename_all = "snake_case")]
218pub enum SchemaKind {
219    String,
220    Binary,
221    Integer,
222    Number,
223    Boolean,
224    Array(Box<SchemaKind>),
225    Object,
226    Any,
227}
228
229/// Validation generated as native Rust code by `#[api_model]`.
230#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
231#[serde(rename_all = "snake_case")]
232pub enum ValidationRule {
233    MinLength(usize),
234    MaxLength(usize),
235    Email,
236    /// An additional accepted serialized field name.
237    Alias(String),
238    /// Stable identity of an application-defined validator.
239    Custom(String),
240    /// Recursively validates a nested model or model collection.
241    Nested,
242}
243
244/// One field in an API model.
245#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
246pub struct FieldDescriptor {
247    pub name: String,
248    pub required: bool,
249    pub ty: TypeDescriptor,
250    pub validation: Vec<ValidationRule>,
251}
252
253impl FieldDescriptor {
254    #[must_use]
255    pub fn new(
256        name: impl Into<String>,
257        required: bool,
258        ty: TypeDescriptor,
259        validation: Vec<ValidationRule>,
260    ) -> Self {
261        Self {
262            name: name.into(),
263            required,
264            ty,
265            validation,
266        }
267    }
268}
269
270/// A complete model used by validation, `OpenAPI`, MCP, and Markdown.
271#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
272pub struct ModelDescriptor {
273    pub name: String,
274    pub fields: Vec<FieldDescriptor>,
275}
276
277impl ModelDescriptor {
278    #[must_use]
279    pub fn new(name: impl Into<String>, fields: Vec<FieldDescriptor>) -> Self {
280        Self {
281            name: name.into(),
282            fields,
283        }
284    }
285}
286
287/// The type identity and schema captured by the Rust frontend.
288#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
289pub struct TypeDescriptor {
290    pub rust_name: String,
291    pub schema: SchemaKind,
292    pub model: Option<Box<ModelDescriptor>>,
293    /// Complete item contract for collections.
294    ///
295    /// `SchemaKind::Array` retains the lightweight JSON shape while this field
296    /// preserves nested model metadata for validation, `OpenAPI`, MCP, and
297    /// compatibility analysis.
298    #[serde(default)]
299    pub items: Option<Box<TypeDescriptor>>,
300    /// Rules the type itself declares, which every value of it must satisfy.
301    ///
302    /// [`FieldDescriptor::validation`] belongs to one field of one model, so a
303    /// value type's rules are lost the moment the type appears anywhere a field
304    /// name does not reach: as a collection item, or nested inside one. Carrying
305    /// them on the type keeps every projection of `Vec<Tag>` as precise as the
306    /// runtime check it is generated beside.
307    #[serde(default)]
308    pub constraints: Vec<ValidationRule>,
309}
310
311impl TypeDescriptor {
312    #[must_use]
313    pub fn new(rust_name: impl Into<String>) -> Self {
314        Self {
315            rust_name: rust_name.into(),
316            schema: SchemaKind::Any,
317            model: None,
318            items: None,
319            constraints: Vec::new(),
320        }
321    }
322
323    #[must_use]
324    pub fn scalar(rust_name: impl Into<String>, schema: SchemaKind) -> Self {
325        Self {
326            rust_name: rust_name.into(),
327            schema,
328            model: None,
329            items: None,
330            constraints: Vec::new(),
331        }
332    }
333
334    #[must_use]
335    pub fn model(model: ModelDescriptor) -> Self {
336        Self {
337            rust_name: model.name.clone(),
338            schema: SchemaKind::Object,
339            model: Some(Box::new(model)),
340            items: None,
341            constraints: Vec::new(),
342        }
343    }
344
345    /// Records the rules the type declares for every value of it.
346    #[must_use]
347    pub fn with_constraints(mut self, constraints: Vec<ValidationRule>) -> Self {
348        self.constraints = constraints;
349        self
350    }
351}
352
353/// A model that can describe and validate itself without runtime reflection.
354pub trait ApiModel {
355    fn model_descriptor() -> ModelDescriptor;
356
357    /// Validates the model using generated native Rust checks.
358    ///
359    /// # Errors
360    ///
361    /// Returns all field violations found in the model.
362    fn validate(&self) -> Result<(), ValidationErrors>;
363}
364
365/// A Rust type that can participate in an operation schema.
366pub trait ApiSchema {
367    fn type_descriptor() -> TypeDescriptor;
368
369    /// Validates a decoded operation argument.
370    ///
371    /// Primitive schemas are valid by default. Models generated with
372    /// `#[api_model]` override this through their [`ApiModel`] implementation.
373    ///
374    /// # Errors
375    ///
376    /// Returns all model validation failures.
377    fn validate_input(&self) -> Result<(), ValidationErrors> {
378        Ok(())
379    }
380}
381
382impl<T: ApiModel> ApiSchema for T {
383    fn type_descriptor() -> TypeDescriptor {
384        TypeDescriptor::model(T::model_descriptor())
385    }
386
387    fn validate_input(&self) -> Result<(), ValidationErrors> {
388        self.validate()
389    }
390}
391
392impl ApiSchema for String {
393    fn type_descriptor() -> TypeDescriptor {
394        TypeDescriptor::scalar("String", SchemaKind::String)
395    }
396}
397
398impl ApiSchema for &str {
399    fn type_descriptor() -> TypeDescriptor {
400        TypeDescriptor::scalar("&str", SchemaKind::String)
401    }
402}
403
404impl ApiSchema for bool {
405    fn type_descriptor() -> TypeDescriptor {
406        TypeDescriptor::scalar("bool", SchemaKind::Boolean)
407    }
408}
409
410macro_rules! integer_schemas {
411    ($($type:ty),+ $(,)?) => {
412        $(
413            impl ApiSchema for $type {
414                fn type_descriptor() -> TypeDescriptor {
415                    TypeDescriptor::scalar(stringify!($type), SchemaKind::Integer)
416                }
417            }
418        )+
419    };
420}
421
422integer_schemas!(
423    u8, u16, u32, u64, u128, usize, i8, i16, i32, i64, i128, isize
424);
425
426macro_rules! number_schemas {
427    ($($type:ty),+ $(,)?) => {
428        $(
429            impl ApiSchema for $type {
430                fn type_descriptor() -> TypeDescriptor {
431                    TypeDescriptor::scalar(stringify!($type), SchemaKind::Number)
432                }
433            }
434        )+
435    };
436}
437
438number_schemas!(f32, f64);
439
440impl<T: ApiSchema> ApiSchema for Vec<T> {
441    fn type_descriptor() -> TypeDescriptor {
442        let item = T::type_descriptor();
443        TypeDescriptor {
444            rust_name: alloc::format!("Vec<{}>", item.rust_name),
445            schema: SchemaKind::Array(Box::new(item.schema.clone())),
446            model: None,
447            items: Some(Box::new(item)),
448            constraints: Vec::new(),
449        }
450    }
451}
452
453impl<T: ApiSchema> ApiSchema for Option<T> {
454    fn type_descriptor() -> TypeDescriptor {
455        T::type_descriptor()
456    }
457}
458
459/// One typed model-validation failure.
460#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
461pub struct FieldViolation {
462    pub field: String,
463    pub code: String,
464    pub message: String,
465}
466
467/// All model-validation failures collected in one pass.
468#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
469pub struct ValidationErrors {
470    violations: Vec<FieldViolation>,
471}
472
473/// A typed domain failure shared by HTTP and MCP projections.
474#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
475pub struct OperationFailure {
476    pub status: u16,
477    pub code: String,
478    pub message: String,
479    pub details: Option<Vec<u8>>,
480    pub headers: Vec<ResponseHeader>,
481}
482
483impl OperationFailure {
484    #[must_use]
485    pub fn new(status: u16, code: impl Into<String>, message: impl Into<String>) -> Self {
486        Self {
487            status,
488            code: code.into(),
489            message: message.into(),
490            details: None,
491            headers: Vec::new(),
492        }
493    }
494
495    #[must_use]
496    pub fn with_details(mut self, details: Vec<u8>) -> Self {
497        self.details = Some(details);
498        self
499    }
500
501    #[must_use]
502    pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
503        self.headers.push(ResponseHeader::new(name, value));
504        self
505    }
506}
507
508/// A response construction failure that must be redacted by transports.
509#[derive(Clone, Debug, Eq, PartialEq)]
510pub struct ResponseBuildError {
511    pub code: String,
512    pub message: String,
513}
514
515impl ResponseBuildError {
516    #[must_use]
517    pub fn serialization_failed() -> Self {
518        Self {
519            code: "serialization_failed".to_string(),
520            message: "operation response could not be serialized".to_string(),
521        }
522    }
523}
524
525/// A user-declared operation error with stable transport semantics.
526pub trait ApiError {
527    fn response_descriptors() -> Vec<ResponseDescriptor>;
528
529    /// Converts the declared error into its transport-neutral failure.
530    ///
531    /// # Errors
532    ///
533    /// Returns a response build error when a typed error payload cannot be
534    /// serialized.
535    fn into_failure(self) -> Result<OperationFailure, ResponseBuildError>;
536}
537
538impl ValidationErrors {
539    #[must_use]
540    pub const fn new() -> Self {
541        Self {
542            violations: Vec::new(),
543        }
544    }
545
546    pub fn push(
547        &mut self,
548        field: impl Into<String>,
549        code: impl Into<String>,
550        message: impl Into<String>,
551    ) {
552        self.violations.push(FieldViolation {
553            field: field.into(),
554            code: code.into(),
555            message: message.into(),
556        });
557    }
558
559    #[must_use]
560    pub fn is_empty(&self) -> bool {
561        self.violations.is_empty()
562    }
563
564    #[must_use]
565    pub fn violations(&self) -> &[FieldViolation] {
566        &self.violations
567    }
568}
569
570/// Conservative, allocation-free email syntax check used by generated code.
571#[doc(hidden)]
572#[must_use]
573pub fn is_email(value: &str) -> bool {
574    let Some((local, domain)) = value.split_once('@') else {
575        return false;
576    };
577    !local.is_empty()
578        && !domain.is_empty()
579        && domain.contains('.')
580        && !value.chars().any(char::is_whitespace)
581}
582
583/// A single successful or error response declared by an operation.
584#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
585pub struct ResponseDescriptor {
586    pub status: u16,
587    pub body: Option<TypeDescriptor>,
588    pub error_code: Option<String>,
589    pub error_message: Option<String>,
590    pub headers: Vec<ResponseHeader>,
591}
592
593/// A response header emitted without transport-specific dependencies.
594#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
595pub struct ResponseHeader {
596    pub name: String,
597    pub value: String,
598}
599
600impl ResponseHeader {
601    #[must_use]
602    pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
603        Self {
604            name: name.into(),
605            value: value.into(),
606        }
607    }
608}
609
610/// The transport-neutral source of one operation argument.
611#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
612#[serde(rename_all = "snake_case")]
613pub enum InputSource {
614    Path,
615    Query,
616    Header,
617    Cookie,
618    Json,
619    Form,
620    Multipart,
621    File,
622    /// Pull-based raw request body, consumed with transport backpressure.
623    Stream,
624}
625
626/// One typed operation argument and its HTTP extraction source.
627#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
628pub struct InputDescriptor {
629    pub name: String,
630    pub source: InputSource,
631    pub required: bool,
632    pub ty: TypeDescriptor,
633}
634
635impl InputDescriptor {
636    #[must_use]
637    pub fn new(
638        name: impl Into<String>,
639        source: InputSource,
640        required: bool,
641        ty: TypeDescriptor,
642    ) -> Self {
643        Self {
644            name: name.into(),
645            source,
646            required,
647            ty,
648        }
649    }
650}
651
652impl ResponseDescriptor {
653    #[must_use]
654    pub fn success(status: u16, body: Option<TypeDescriptor>) -> Self {
655        Self {
656            status,
657            body,
658            error_code: None,
659            error_message: None,
660            headers: Vec::new(),
661        }
662    }
663
664    #[must_use]
665    pub fn error(
666        status: u16,
667        code: impl Into<String>,
668        message: impl Into<String>,
669        body: Option<TypeDescriptor>,
670    ) -> Self {
671        Self {
672            status,
673            body,
674            error_code: Some(code.into()),
675            error_message: Some(message.into()),
676            headers: Vec::new(),
677        }
678    }
679
680    #[must_use]
681    pub fn with_headers(mut self, headers: Vec<ResponseHeader>) -> Self {
682        self.headers = headers;
683        self
684    }
685}
686
687/// Agent-visible risk associated with invoking an operation.
688#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
689#[serde(rename_all = "snake_case")]
690pub enum OperationRisk {
691    #[default]
692    Read,
693    Write,
694    Destructive,
695}
696
697/// Whether an agent must ask for confirmation before invoking an operation.
698#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
699#[serde(rename_all = "snake_case")]
700pub enum Confirmation {
701    #[default]
702    Never,
703    Required,
704}
705
706/// Protocol-neutral metadata used by MCP and other agent transports.
707#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
708pub struct AgentPolicy {
709    pub risk: OperationRisk,
710    pub confirmation: Confirmation,
711    pub idempotent: bool,
712}
713
714/// How much operation output may be exposed to an agent.
715#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
716#[serde(rename_all = "snake_case")]
717pub enum OutputExposure {
718    #[default]
719    Full,
720    SummaryOnly,
721    None,
722}
723
724/// MCP tool semantics declared alongside an operation.
725#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
726pub struct McpToolDescriptor {
727    pub name: String,
728    pub description: String,
729    pub expose_output: OutputExposure,
730}
731
732impl McpToolDescriptor {
733    #[must_use]
734    pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
735        Self {
736            name: name.into(),
737            description: description.into(),
738            expose_output: OutputExposure::Full,
739        }
740    }
741
742    #[must_use]
743    pub const fn with_output_exposure(mut self, exposure: OutputExposure) -> Self {
744        self.expose_output = exposure;
745        self
746    }
747}
748
749/// A typed dependency declared by an operation handler.
750#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
751pub struct DependencyDescriptor {
752    pub rust_name: String,
753}
754
755impl DependencyDescriptor {
756    #[must_use]
757    pub fn new(rust_name: impl Into<String>) -> Self {
758        Self {
759            rust_name: rust_name.into(),
760        }
761    }
762}
763
764/// HTTP location used by an API-key security scheme.
765#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
766#[serde(rename_all = "snake_case")]
767pub enum SecurityLocation {
768    Header,
769    Query,
770    Cookie,
771}
772
773/// Transport-independent description of an application security scheme.
774#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
775#[serde(tag = "type", rename_all = "snake_case")]
776pub enum SecuritySchemeKind {
777    ApiKey {
778        location: SecurityLocation,
779        name: String,
780    },
781    Http {
782        scheme: String,
783        bearer_format: Option<String>,
784    },
785    OAuth2 {
786        authorization_url: Option<String>,
787        token_url: Option<String>,
788        scopes: Vec<String>,
789    },
790    OpenIdConnect {
791        discovery_url: String,
792    },
793    MutualTls,
794}
795
796/// Named security scheme registered by an application.
797#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
798pub struct SecuritySchemeDescriptor {
799    pub name: String,
800    pub description: Option<String>,
801    pub kind: SecuritySchemeKind,
802}
803
804impl SecuritySchemeDescriptor {
805    #[must_use]
806    pub fn new(name: impl Into<String>, kind: SecuritySchemeKind) -> Self {
807        Self {
808            name: name.into(),
809            description: None,
810            kind,
811        }
812    }
813
814    #[must_use]
815    pub fn with_description(mut self, description: impl Into<String>) -> Self {
816        self.description = Some(description.into());
817        self
818    }
819}
820
821/// Security scheme and scopes required by one operation.
822#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
823pub struct SecurityRequirement {
824    pub scheme: String,
825    pub scopes: Vec<String>,
826}
827
828impl SecurityRequirement {
829    #[must_use]
830    pub fn new(scheme: impl Into<String>) -> Self {
831        Self {
832            scheme: scheme.into(),
833            scopes: Vec::new(),
834        }
835    }
836
837    #[must_use]
838    pub fn with_scopes(mut self, scopes: Vec<String>) -> Self {
839        self.scopes = scopes;
840        self
841    }
842}
843
844/// The protocol-neutral semantic contract for one operation.
845#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
846pub struct OperationContract {
847    #[serde(default)]
848    pub format_version: ContractFormatVersion,
849    pub id: OperationId,
850    pub summary: String,
851    /// Deprecated single-body view retained for serialized snapshot migration.
852    ///
853    /// Canonical encoding uses `inputs`, which is the source of truth.
854    pub input: Option<TypeDescriptor>,
855    pub inputs: Vec<InputDescriptor>,
856    #[serde(default)]
857    pub dependencies: Vec<DependencyDescriptor>,
858    #[serde(default)]
859    pub security: Vec<SecurityRequirement>,
860    pub responses: Vec<ResponseDescriptor>,
861    #[serde(default)]
862    pub agent: AgentPolicy,
863    #[serde(default)]
864    pub mcp: Option<McpToolDescriptor>,
865}
866
867impl OperationContract {
868    /// Creates a protocol-neutral operation contract.
869    ///
870    /// # Errors
871    ///
872    /// Returns [`InvalidOperationId`] when `id` is not a valid stable
873    /// operation identity.
874    pub fn new(
875        id: impl Into<String>,
876        summary: impl Into<String>,
877        input: Option<TypeDescriptor>,
878        responses: Vec<ResponseDescriptor>,
879    ) -> Result<Self, InvalidOperationId> {
880        let inputs = input
881            .as_ref()
882            .map(|input| {
883                vec![InputDescriptor::new(
884                    "body",
885                    InputSource::Json,
886                    true,
887                    input.clone(),
888                )]
889            })
890            .unwrap_or_default();
891        Ok(Self {
892            format_version: CURRENT_CONTRACT_FORMAT_VERSION,
893            id: OperationId::new(id)?,
894            summary: summary.into(),
895            input,
896            inputs,
897            dependencies: Vec::new(),
898            security: Vec::new(),
899            responses,
900            agent: AgentPolicy::default(),
901            mcp: None,
902        })
903    }
904
905    #[must_use]
906    pub fn with_agent_policy(mut self, policy: AgentPolicy) -> Self {
907        self.agent = policy;
908        self
909    }
910
911    #[must_use]
912    pub fn with_inputs(mut self, inputs: Vec<InputDescriptor>) -> Self {
913        self.input = inputs
914            .iter()
915            .find(|input| input.source == InputSource::Json)
916            .map(|input| input.ty.clone());
917        self.inputs = inputs;
918        self
919    }
920
921    #[must_use]
922    pub fn with_dependencies(mut self, dependencies: Vec<DependencyDescriptor>) -> Self {
923        self.dependencies = dependencies;
924        self
925    }
926
927    #[must_use]
928    pub fn with_security(mut self, security: Vec<SecurityRequirement>) -> Self {
929        self.security = security;
930        self
931    }
932
933    #[must_use]
934    pub fn with_mcp_tool(mut self, tool: McpToolDescriptor) -> Self {
935        self.mcp = Some(tool);
936        self
937    }
938
939    /// Returns the deterministic, versioned canonical binary representation.
940    ///
941    /// The encoding deliberately excludes the deprecated `input` mirror and
942    /// uses `inputs` as its only source of truth.
943    #[must_use]
944    pub fn canonical_bytes(&self) -> Vec<u8> {
945        let mut encoder = CanonicalEncoder::new();
946        encoder.bytes(b"blazingly.operation-contract");
947        encoder.u16(self.format_version.major);
948        encoder.u16(self.format_version.minor);
949        encoder.operation(self);
950        encoder.finish()
951    }
952
953    /// Returns the SHA-256 identity of the canonical representation.
954    #[must_use]
955    pub fn fingerprint(&self) -> ContractFingerprint {
956        let digest = Sha256::digest(self.canonical_bytes());
957        let mut bytes = [0_u8; 32];
958        bytes.copy_from_slice(&digest);
959        ContractFingerprint::new(self.format_version, bytes)
960    }
961}
962
963struct CanonicalEncoder {
964    output: Vec<u8>,
965}
966
967impl CanonicalEncoder {
968    fn new() -> Self {
969        Self { output: Vec::new() }
970    }
971
972    fn finish(self) -> Vec<u8> {
973        self.output
974    }
975
976    fn u8(&mut self, value: u8) {
977        self.output.push(value);
978    }
979
980    fn u16(&mut self, value: u16) {
981        self.output.extend_from_slice(&value.to_be_bytes());
982    }
983
984    fn u64(&mut self, value: u64) {
985        self.output.extend_from_slice(&value.to_be_bytes());
986    }
987
988    fn bool(&mut self, value: bool) {
989        self.u8(u8::from(value));
990    }
991
992    fn bytes(&mut self, value: &[u8]) {
993        self.u64(value.len() as u64);
994        self.output.extend_from_slice(value);
995    }
996
997    fn string(&mut self, value: &str) {
998        self.bytes(value.as_bytes());
999    }
1000
1001    fn optional_string(&mut self, value: Option<&str>) {
1002        match value {
1003            Some(value) => {
1004                self.u8(1);
1005                self.string(value);
1006            }
1007            None => self.u8(0),
1008        }
1009    }
1010
1011    /// Encodes a semantically unordered collection as sorted item encodings.
1012    fn sorted<T>(&mut self, values: &[T], mut encode: impl FnMut(&mut Self, &T)) {
1013        let mut encoded = Vec::with_capacity(values.len());
1014        for value in values {
1015            let mut item = Self::new();
1016            encode(&mut item, value);
1017            encoded.push(item.finish());
1018        }
1019        encoded.sort();
1020
1021        self.u64(encoded.len() as u64);
1022        for item in encoded {
1023            self.bytes(&item);
1024        }
1025    }
1026
1027    fn operation(&mut self, contract: &OperationContract) {
1028        self.string(contract.id.as_str());
1029        self.string(&contract.summary);
1030        self.sorted(&contract.inputs, Self::input);
1031        self.sorted(&contract.dependencies, |encoder, dependency| {
1032            encoder.string(&dependency.rust_name);
1033        });
1034        self.sorted(&contract.security, Self::security_requirement);
1035        self.sorted(&contract.responses, Self::response);
1036        self.agent(&contract.agent);
1037        match &contract.mcp {
1038            Some(tool) => {
1039                self.u8(1);
1040                self.mcp(tool);
1041            }
1042            None => self.u8(0),
1043        }
1044    }
1045
1046    fn input(&mut self, input: &InputDescriptor) {
1047        self.u8(input_source_tag(input.source));
1048        if input.source == InputSource::Header {
1049            self.string(&input.name.to_ascii_lowercase());
1050        } else {
1051            self.string(&input.name);
1052        }
1053        self.bool(input.required);
1054        self.ty(&input.ty);
1055    }
1056
1057    fn ty(&mut self, ty: &TypeDescriptor) {
1058        self.string(&ty.rust_name);
1059        self.schema(&ty.schema);
1060        match &ty.model {
1061            Some(model) => {
1062                self.u8(1);
1063                self.model(model);
1064            }
1065            None => self.u8(0),
1066        }
1067        match &ty.items {
1068            Some(items) => {
1069                self.u8(1);
1070                self.ty(items);
1071            }
1072            None => self.u8(0),
1073        }
1074        self.sorted(&ty.constraints, Self::validation);
1075    }
1076
1077    fn schema(&mut self, schema: &SchemaKind) {
1078        match schema {
1079            SchemaKind::String => self.u8(0),
1080            SchemaKind::Binary => self.u8(1),
1081            SchemaKind::Integer => self.u8(2),
1082            SchemaKind::Number => self.u8(3),
1083            SchemaKind::Boolean => self.u8(4),
1084            SchemaKind::Array(item) => {
1085                self.u8(5);
1086                self.schema(item);
1087            }
1088            SchemaKind::Object => self.u8(6),
1089            SchemaKind::Any => self.u8(7),
1090        }
1091    }
1092
1093    fn model(&mut self, model: &ModelDescriptor) {
1094        self.string(&model.name);
1095        self.sorted(&model.fields, Self::field);
1096    }
1097
1098    fn field(&mut self, field: &FieldDescriptor) {
1099        self.string(&field.name);
1100        self.bool(field.required);
1101        self.ty(&field.ty);
1102        self.sorted(&field.validation, Self::validation);
1103    }
1104
1105    fn validation(&mut self, rule: &ValidationRule) {
1106        match rule {
1107            ValidationRule::MinLength(value) => {
1108                self.u8(0);
1109                self.u64(*value as u64);
1110            }
1111            ValidationRule::MaxLength(value) => {
1112                self.u8(1);
1113                self.u64(*value as u64);
1114            }
1115            ValidationRule::Email => self.u8(2),
1116            ValidationRule::Alias(alias) => {
1117                self.u8(3);
1118                self.string(alias);
1119            }
1120            ValidationRule::Custom(validator) => {
1121                self.u8(4);
1122                self.string(validator);
1123            }
1124            ValidationRule::Nested => self.u8(5),
1125        }
1126    }
1127
1128    fn response(&mut self, response: &ResponseDescriptor) {
1129        self.u16(response.status);
1130        match &response.body {
1131            Some(body) => {
1132                self.u8(1);
1133                self.ty(body);
1134            }
1135            None => self.u8(0),
1136        }
1137        self.optional_string(response.error_code.as_deref());
1138        self.optional_string(response.error_message.as_deref());
1139        self.sorted(&response.headers, |encoder, header| {
1140            encoder.string(&header.name.to_ascii_lowercase());
1141            encoder.string(&header.value);
1142        });
1143    }
1144
1145    fn security_requirement(&mut self, requirement: &SecurityRequirement) {
1146        self.string(&requirement.scheme);
1147        self.sorted(&requirement.scopes, |encoder, scope| encoder.string(scope));
1148    }
1149
1150    fn agent(&mut self, policy: &AgentPolicy) {
1151        self.u8(risk_tag(policy.risk));
1152        self.u8(confirmation_tag(policy.confirmation));
1153        self.bool(policy.idempotent);
1154    }
1155
1156    fn mcp(&mut self, tool: &McpToolDescriptor) {
1157        self.string(&tool.name);
1158        self.string(&tool.description);
1159        self.u8(exposure_tag(tool.expose_output));
1160    }
1161}
1162
1163impl Compatibility {
1164    /// Compares two operation contracts using transport-neutral compatibility
1165    /// rules. Findings are returned in deterministic path/code order.
1166    #[must_use]
1167    pub fn compare(
1168        previous: &OperationContract,
1169        current: &OperationContract,
1170    ) -> CompatibilityReport {
1171        let previous_fingerprint = previous.fingerprint();
1172        let current_fingerprint = current.fingerprint();
1173        let mut report = CompatibilityReport {
1174            previous: previous_fingerprint,
1175            current: current_fingerprint,
1176            changes: Vec::new(),
1177        };
1178
1179        if previous_fingerprint == current_fingerprint {
1180            return report;
1181        }
1182
1183        compare_versions(previous, current, &mut report.changes);
1184        compare_identity(previous, current, &mut report.changes);
1185        compare_inputs(previous, current, &mut report.changes);
1186        compare_dependencies(previous, current, &mut report.changes);
1187        compare_security(previous, current, &mut report.changes);
1188        compare_responses(previous, current, &mut report.changes);
1189        compare_agent(previous, current, &mut report.changes);
1190        compare_mcp(previous, current, &mut report.changes);
1191
1192        report.changes.sort_by(|left, right| {
1193            (&left.path, &left.code, impact_tag(left.impact)).cmp(&(
1194                &right.path,
1195                &right.code,
1196                impact_tag(right.impact),
1197            ))
1198        });
1199        report
1200    }
1201}
1202
1203fn compare_versions(
1204    previous: &OperationContract,
1205    current: &OperationContract,
1206    changes: &mut Vec<CompatibilityChange>,
1207) {
1208    if previous.format_version.major != current.format_version.major {
1209        changes.push(change(
1210            CompatibilityImpact::Breaking,
1211            "format_version",
1212            "format_major_changed",
1213            format!(
1214                "canonical contract format changed from {} to {}",
1215                previous.format_version, current.format_version
1216            ),
1217        ));
1218    } else if previous.format_version.minor != current.format_version.minor {
1219        changes.push(change(
1220            CompatibilityImpact::Metadata,
1221            "format_version",
1222            "format_minor_changed",
1223            format!(
1224                "canonical contract format changed from {} to {}",
1225                previous.format_version, current.format_version
1226            ),
1227        ));
1228    }
1229}
1230
1231fn compare_identity(
1232    previous: &OperationContract,
1233    current: &OperationContract,
1234    changes: &mut Vec<CompatibilityChange>,
1235) {
1236    if previous.id != current.id {
1237        changes.push(change(
1238            CompatibilityImpact::Breaking,
1239            "id",
1240            "operation_id_changed",
1241            format!(
1242                "operation id changed from {} to {}",
1243                previous.id, current.id
1244            ),
1245        ));
1246    }
1247    if previous.summary != current.summary {
1248        changes.push(change(
1249            CompatibilityImpact::Metadata,
1250            "summary",
1251            "summary_changed",
1252            "operation summary changed",
1253        ));
1254    }
1255}
1256
1257fn compare_inputs(
1258    previous: &OperationContract,
1259    current: &OperationContract,
1260    changes: &mut Vec<CompatibilityChange>,
1261) {
1262    for old in &previous.inputs {
1263        let path = input_path(old);
1264        let Some(new) = current
1265            .inputs
1266            .iter()
1267            .find(|candidate| same_input_key(old, candidate))
1268        else {
1269            changes.push(change(
1270                CompatibilityImpact::Breaking,
1271                path,
1272                "input_removed",
1273                "an accepted operation input was removed",
1274            ));
1275            continue;
1276        };
1277
1278        match (old.required, new.required) {
1279            (false, true) => changes.push(change(
1280                CompatibilityImpact::Breaking,
1281                format!("{path}.required"),
1282                "input_became_required",
1283                "an optional input became required",
1284            )),
1285            (true, false) => changes.push(change(
1286                CompatibilityImpact::NonBreaking,
1287                format!("{path}.required"),
1288                "input_became_optional",
1289                "a required input became optional",
1290            )),
1291            _ => {}
1292        }
1293        compare_type(
1294            &old.ty,
1295            &new.ty,
1296            &format!("{path}.type"),
1297            TypeDirection::Input,
1298            changes,
1299        );
1300    }
1301
1302    for new in &current.inputs {
1303        if previous
1304            .inputs
1305            .iter()
1306            .any(|candidate| same_input_key(candidate, new))
1307        {
1308            continue;
1309        }
1310        changes.push(change(
1311            if new.required {
1312                CompatibilityImpact::Breaking
1313            } else {
1314                CompatibilityImpact::NonBreaking
1315            },
1316            input_path(new),
1317            if new.required {
1318                "required_input_added"
1319            } else {
1320                "optional_input_added"
1321            },
1322            if new.required {
1323                "a new required operation input was added"
1324            } else {
1325                "a new optional operation input was added"
1326            },
1327        ));
1328    }
1329}
1330
1331fn compare_dependencies(
1332    previous: &OperationContract,
1333    current: &OperationContract,
1334    changes: &mut Vec<CompatibilityChange>,
1335) {
1336    for old in &previous.dependencies {
1337        if !current
1338            .dependencies
1339            .iter()
1340            .any(|new| new.rust_name == old.rust_name)
1341        {
1342            changes.push(change(
1343                CompatibilityImpact::NonBreaking,
1344                format!("dependencies.{}", old.rust_name),
1345                "dependency_removed",
1346                "an operation dependency was removed",
1347            ));
1348        }
1349    }
1350    for new in &current.dependencies {
1351        if !previous
1352            .dependencies
1353            .iter()
1354            .any(|old| old.rust_name == new.rust_name)
1355        {
1356            changes.push(change(
1357                CompatibilityImpact::Breaking,
1358                format!("dependencies.{}", new.rust_name),
1359                "dependency_added",
1360                "a new operation dependency must be provided",
1361            ));
1362        }
1363    }
1364}
1365
1366fn compare_security(
1367    previous: &OperationContract,
1368    current: &OperationContract,
1369    changes: &mut Vec<CompatibilityChange>,
1370) {
1371    for old in &previous.security {
1372        let Some(new) = current
1373            .security
1374            .iter()
1375            .find(|candidate| candidate.scheme == old.scheme)
1376        else {
1377            changes.push(change(
1378                CompatibilityImpact::NonBreaking,
1379                format!("security.{}", old.scheme),
1380                "security_requirement_removed",
1381                "a security requirement was removed",
1382            ));
1383            continue;
1384        };
1385        compare_string_set(
1386            &old.scopes,
1387            &new.scopes,
1388            &format!("security.{}.scopes", old.scheme),
1389            "security_scope",
1390            CompatibilityImpact::Breaking,
1391            CompatibilityImpact::NonBreaking,
1392            changes,
1393        );
1394    }
1395    for new in &current.security {
1396        if !previous.security.iter().any(|old| old.scheme == new.scheme) {
1397            changes.push(change(
1398                CompatibilityImpact::Breaking,
1399                format!("security.{}", new.scheme),
1400                "security_requirement_added",
1401                "a new security requirement was added",
1402            ));
1403        }
1404    }
1405}
1406
1407fn compare_responses(
1408    previous: &OperationContract,
1409    current: &OperationContract,
1410    changes: &mut Vec<CompatibilityChange>,
1411) {
1412    for old in &previous.responses {
1413        let path = response_path(old);
1414        let Some(new) = current
1415            .responses
1416            .iter()
1417            .find(|candidate| same_response_key(old, candidate))
1418        else {
1419            changes.push(change(
1420                CompatibilityImpact::Breaking,
1421                path,
1422                "response_removed",
1423                "a declared operation response was removed",
1424            ));
1425            continue;
1426        };
1427
1428        match (&old.body, &new.body) {
1429            (Some(old), Some(new)) => compare_type(
1430                old,
1431                new,
1432                &format!("{path}.body"),
1433                TypeDirection::Output,
1434                changes,
1435            ),
1436            (Some(_), None) => changes.push(change(
1437                CompatibilityImpact::Breaking,
1438                format!("{path}.body"),
1439                "response_body_removed",
1440                "a declared response body was removed",
1441            )),
1442            (None, Some(_)) => changes.push(change(
1443                CompatibilityImpact::NonBreaking,
1444                format!("{path}.body"),
1445                "response_body_added",
1446                "a response body was added",
1447            )),
1448            (None, None) => {}
1449        }
1450
1451        if old.error_message != new.error_message {
1452            changes.push(change(
1453                CompatibilityImpact::Metadata,
1454                format!("{path}.error_message"),
1455                "error_message_changed",
1456                "a declared error message changed",
1457            ));
1458        }
1459        compare_response_headers(&old.headers, &new.headers, &path, changes);
1460    }
1461
1462    for new in &current.responses {
1463        if !previous
1464            .responses
1465            .iter()
1466            .any(|candidate| same_response_key(candidate, new))
1467        {
1468            changes.push(change(
1469                CompatibilityImpact::NonBreaking,
1470                response_path(new),
1471                "response_added",
1472                "a new operation response was declared",
1473            ));
1474        }
1475    }
1476}
1477
1478fn compare_response_headers(
1479    previous: &[ResponseHeader],
1480    current: &[ResponseHeader],
1481    response_path: &str,
1482    changes: &mut Vec<CompatibilityChange>,
1483) {
1484    for old in previous {
1485        let path = format!("{response_path}.headers.{}", old.name.to_ascii_lowercase());
1486        let Some(new) = current
1487            .iter()
1488            .find(|header| header.name.eq_ignore_ascii_case(&old.name))
1489        else {
1490            changes.push(change(
1491                CompatibilityImpact::Breaking,
1492                path,
1493                "response_header_removed",
1494                "a declared response header was removed",
1495            ));
1496            continue;
1497        };
1498        if old.value != new.value {
1499            changes.push(change(
1500                CompatibilityImpact::Breaking,
1501                path,
1502                "response_header_value_changed",
1503                "a declared response header value changed",
1504            ));
1505        }
1506    }
1507    for new in current {
1508        if !previous
1509            .iter()
1510            .any(|header| header.name.eq_ignore_ascii_case(&new.name))
1511        {
1512            changes.push(change(
1513                CompatibilityImpact::NonBreaking,
1514                format!("{response_path}.headers.{}", new.name.to_ascii_lowercase()),
1515                "response_header_added",
1516                "a new response header was declared",
1517            ));
1518        }
1519    }
1520}
1521
1522fn compare_agent(
1523    previous: &OperationContract,
1524    current: &OperationContract,
1525    changes: &mut Vec<CompatibilityChange>,
1526) {
1527    let previous_risk = risk_tag(previous.agent.risk);
1528    let current_risk = risk_tag(current.agent.risk);
1529    if previous_risk != current_risk {
1530        changes.push(change(
1531            if current_risk > previous_risk {
1532                CompatibilityImpact::Breaking
1533            } else {
1534                CompatibilityImpact::NonBreaking
1535            },
1536            "agent.risk",
1537            "agent_risk_changed",
1538            "agent invocation risk changed",
1539        ));
1540    }
1541
1542    if previous.agent.confirmation != current.agent.confirmation {
1543        changes.push(change(
1544            if current.agent.confirmation == Confirmation::Required {
1545                CompatibilityImpact::Breaking
1546            } else {
1547                CompatibilityImpact::NonBreaking
1548            },
1549            "agent.confirmation",
1550            "agent_confirmation_changed",
1551            "agent confirmation policy changed",
1552        ));
1553    }
1554
1555    if previous.agent.idempotent != current.agent.idempotent {
1556        changes.push(change(
1557            if previous.agent.idempotent {
1558                CompatibilityImpact::Breaking
1559            } else {
1560                CompatibilityImpact::NonBreaking
1561            },
1562            "agent.idempotent",
1563            "agent_idempotency_changed",
1564            "agent idempotency guarantee changed",
1565        ));
1566    }
1567}
1568
1569fn compare_mcp(
1570    previous: &OperationContract,
1571    current: &OperationContract,
1572    changes: &mut Vec<CompatibilityChange>,
1573) {
1574    match (&previous.mcp, &current.mcp) {
1575        (Some(old), Some(new)) => {
1576            if old.name != new.name {
1577                changes.push(change(
1578                    CompatibilityImpact::Breaking,
1579                    "mcp.name",
1580                    "mcp_tool_name_changed",
1581                    "MCP tool name changed",
1582                ));
1583            }
1584            if old.description != new.description {
1585                changes.push(change(
1586                    CompatibilityImpact::Metadata,
1587                    "mcp.description",
1588                    "mcp_description_changed",
1589                    "MCP tool description changed",
1590                ));
1591            }
1592            let old_exposure = exposure_tag(old.expose_output);
1593            let new_exposure = exposure_tag(new.expose_output);
1594            if old_exposure != new_exposure {
1595                changes.push(change(
1596                    if new_exposure > old_exposure {
1597                        CompatibilityImpact::Breaking
1598                    } else {
1599                        CompatibilityImpact::NonBreaking
1600                    },
1601                    "mcp.expose_output",
1602                    "mcp_output_exposure_changed",
1603                    "MCP output exposure policy changed",
1604                ));
1605            }
1606        }
1607        (Some(_), None) => changes.push(change(
1608            CompatibilityImpact::Breaking,
1609            "mcp",
1610            "mcp_tool_removed",
1611            "MCP tool projection was removed",
1612        )),
1613        (None, Some(_)) => changes.push(change(
1614            CompatibilityImpact::NonBreaking,
1615            "mcp",
1616            "mcp_tool_added",
1617            "MCP tool projection was added",
1618        )),
1619        (None, None) => {}
1620    }
1621}
1622
1623#[derive(Clone, Copy)]
1624enum TypeDirection {
1625    Input,
1626    Output,
1627}
1628
1629fn compare_type(
1630    previous: &TypeDescriptor,
1631    current: &TypeDescriptor,
1632    path: &str,
1633    direction: TypeDirection,
1634    changes: &mut Vec<CompatibilityChange>,
1635) {
1636    if previous.schema != current.schema {
1637        changes.push(change(
1638            CompatibilityImpact::Breaking,
1639            format!("{path}.schema"),
1640            "schema_changed",
1641            "wire schema changed",
1642        ));
1643        return;
1644    }
1645    if previous.rust_name != current.rust_name {
1646        changes.push(change(
1647            CompatibilityImpact::Metadata,
1648            format!("{path}.rust_name"),
1649            "rust_type_name_changed",
1650            "Rust type name changed without changing the wire shape",
1651        ));
1652    }
1653    compare_validation(
1654        &previous.constraints,
1655        &current.constraints,
1656        &format!("{path}.constraints"),
1657        direction,
1658        changes,
1659    );
1660
1661    match (&previous.items, &current.items) {
1662        (Some(old), Some(new)) => {
1663            compare_type(old, new, &format!("{path}.items"), direction, changes);
1664        }
1665        (Some(_), None) => changes.push(change(
1666            CompatibilityImpact::Breaking,
1667            format!("{path}.items"),
1668            "collection_item_contract_removed",
1669            "collection item contract precision was removed",
1670        )),
1671        (None, Some(_)) => changes.push(change(
1672            match direction {
1673                TypeDirection::Input => CompatibilityImpact::Breaking,
1674                TypeDirection::Output => CompatibilityImpact::NonBreaking,
1675            },
1676            format!("{path}.items"),
1677            "collection_item_contract_added",
1678            "collection item contract precision was added",
1679        )),
1680        (None, None) => {}
1681    }
1682
1683    match (&previous.model, &current.model) {
1684        (Some(old), Some(new)) => compare_model(old, new, path, direction, changes),
1685        (Some(_), None) => changes.push(change(
1686            CompatibilityImpact::Breaking,
1687            format!("{path}.model"),
1688            "model_contract_removed",
1689            "model contract precision was removed",
1690        )),
1691        (None, Some(_)) => changes.push(change(
1692            match direction {
1693                TypeDirection::Input => CompatibilityImpact::Breaking,
1694                TypeDirection::Output => CompatibilityImpact::NonBreaking,
1695            },
1696            format!("{path}.model"),
1697            "model_contract_added",
1698            "model contract precision was added",
1699        )),
1700        (None, None) => {}
1701    }
1702}
1703
1704fn compare_model(
1705    previous: &ModelDescriptor,
1706    current: &ModelDescriptor,
1707    path: &str,
1708    direction: TypeDirection,
1709    changes: &mut Vec<CompatibilityChange>,
1710) {
1711    if previous.name != current.name {
1712        changes.push(change(
1713            CompatibilityImpact::Metadata,
1714            format!("{path}.model.name"),
1715            "model_name_changed",
1716            "model name changed without changing its wire shape",
1717        ));
1718    }
1719
1720    for old in &previous.fields {
1721        let field_path = format!("{path}.fields.{}", old.name);
1722        let Some(new) = current.fields.iter().find(|field| field.name == old.name) else {
1723            changes.push(change(
1724                CompatibilityImpact::Breaking,
1725                field_path,
1726                "model_field_removed",
1727                "a model field was removed",
1728            ));
1729            continue;
1730        };
1731
1732        if old.required != new.required {
1733            let impact = match (direction, old.required, new.required) {
1734                (TypeDirection::Input, false, true) | (TypeDirection::Output, true, false) => {
1735                    CompatibilityImpact::Breaking
1736                }
1737                _ => CompatibilityImpact::NonBreaking,
1738            };
1739            changes.push(change(
1740                impact,
1741                format!("{field_path}.required"),
1742                "model_field_requiredness_changed",
1743                "model field requiredness changed",
1744            ));
1745        }
1746        compare_type(
1747            &old.ty,
1748            &new.ty,
1749            &format!("{field_path}.type"),
1750            direction,
1751            changes,
1752        );
1753        compare_validation(
1754            &old.validation,
1755            &new.validation,
1756            &field_path,
1757            direction,
1758            changes,
1759        );
1760    }
1761
1762    for new in &current.fields {
1763        if previous.fields.iter().any(|field| field.name == new.name) {
1764            continue;
1765        }
1766        let impact = match direction {
1767            TypeDirection::Input if new.required => CompatibilityImpact::Breaking,
1768            TypeDirection::Input | TypeDirection::Output => CompatibilityImpact::NonBreaking,
1769        };
1770        changes.push(change(
1771            impact,
1772            format!("{path}.fields.{}", new.name),
1773            if new.required {
1774                "required_model_field_added"
1775            } else {
1776                "optional_model_field_added"
1777            },
1778            "a model field was added",
1779        ));
1780    }
1781}
1782
1783fn compare_validation(
1784    previous: &[ValidationRule],
1785    current: &[ValidationRule],
1786    path: &str,
1787    direction: TypeDirection,
1788    changes: &mut Vec<CompatibilityChange>,
1789) {
1790    if matches!(direction, TypeDirection::Output) {
1791        if previous != current {
1792            changes.push(change(
1793                CompatibilityImpact::Metadata,
1794                format!("{path}.validation"),
1795                "output_validation_changed",
1796                "output model validation metadata changed",
1797            ));
1798        }
1799        return;
1800    }
1801
1802    compare_min_length(previous, current, path, changes);
1803    compare_max_length(previous, current, path, changes);
1804    let old_email = previous
1805        .iter()
1806        .any(|rule| matches!(rule, ValidationRule::Email));
1807    let new_email = current
1808        .iter()
1809        .any(|rule| matches!(rule, ValidationRule::Email));
1810    if old_email != new_email {
1811        changes.push(change(
1812            if new_email {
1813                CompatibilityImpact::Breaking
1814            } else {
1815                CompatibilityImpact::NonBreaking
1816            },
1817            format!("{path}.validation.email"),
1818            "email_validation_changed",
1819            "email validation changed",
1820        ));
1821    }
1822    compare_validation_string_rules(
1823        previous,
1824        current,
1825        path,
1826        "alias",
1827        |rule| match rule {
1828            ValidationRule::Alias(value) => Some(value),
1829            ValidationRule::MinLength(_)
1830            | ValidationRule::MaxLength(_)
1831            | ValidationRule::Email
1832            | ValidationRule::Custom(_)
1833            | ValidationRule::Nested => None,
1834        },
1835        CompatibilityImpact::NonBreaking,
1836        CompatibilityImpact::Breaking,
1837        changes,
1838    );
1839    compare_validation_string_rules(
1840        previous,
1841        current,
1842        path,
1843        "custom_validator",
1844        |rule| match rule {
1845            ValidationRule::Custom(value) => Some(value),
1846            ValidationRule::MinLength(_)
1847            | ValidationRule::MaxLength(_)
1848            | ValidationRule::Email
1849            | ValidationRule::Alias(_)
1850            | ValidationRule::Nested => None,
1851        },
1852        CompatibilityImpact::Breaking,
1853        CompatibilityImpact::NonBreaking,
1854        changes,
1855    );
1856    let old_nested = previous
1857        .iter()
1858        .any(|rule| matches!(rule, ValidationRule::Nested));
1859    let new_nested = current
1860        .iter()
1861        .any(|rule| matches!(rule, ValidationRule::Nested));
1862    if old_nested != new_nested {
1863        changes.push(change(
1864            if new_nested {
1865                CompatibilityImpact::Breaking
1866            } else {
1867                CompatibilityImpact::NonBreaking
1868            },
1869            format!("{path}.validation.nested"),
1870            "nested_validation_changed",
1871            "nested model validation changed",
1872        ));
1873    }
1874}
1875
1876#[allow(clippy::too_many_arguments)]
1877fn compare_validation_string_rules<'rule>(
1878    previous: &'rule [ValidationRule],
1879    current: &'rule [ValidationRule],
1880    path: &str,
1881    code_prefix: &str,
1882    value: impl Fn(&'rule ValidationRule) -> Option<&'rule String>,
1883    added_impact: CompatibilityImpact,
1884    removed_impact: CompatibilityImpact,
1885    changes: &mut Vec<CompatibilityChange>,
1886) {
1887    let previous = previous
1888        .iter()
1889        .filter_map(&value)
1890        .cloned()
1891        .collect::<Vec<_>>();
1892    let current = current
1893        .iter()
1894        .filter_map(value)
1895        .cloned()
1896        .collect::<Vec<_>>();
1897    compare_string_set(
1898        &previous,
1899        &current,
1900        &format!("{path}.validation.{code_prefix}"),
1901        code_prefix,
1902        added_impact,
1903        removed_impact,
1904        changes,
1905    );
1906}
1907
1908fn compare_min_length(
1909    previous: &[ValidationRule],
1910    current: &[ValidationRule],
1911    path: &str,
1912    changes: &mut Vec<CompatibilityChange>,
1913) {
1914    let old = previous.iter().filter_map(|rule| match rule {
1915        ValidationRule::MinLength(value) => Some(*value),
1916        ValidationRule::MaxLength(_)
1917        | ValidationRule::Email
1918        | ValidationRule::Alias(_)
1919        | ValidationRule::Custom(_)
1920        | ValidationRule::Nested => None,
1921    });
1922    let new = current.iter().filter_map(|rule| match rule {
1923        ValidationRule::MinLength(value) => Some(*value),
1924        ValidationRule::MaxLength(_)
1925        | ValidationRule::Email
1926        | ValidationRule::Alias(_)
1927        | ValidationRule::Custom(_)
1928        | ValidationRule::Nested => None,
1929    });
1930    let old = old.max();
1931    let new = new.max();
1932    if old != new {
1933        changes.push(change(
1934            if new.unwrap_or(0) > old.unwrap_or(0) {
1935                CompatibilityImpact::Breaking
1936            } else {
1937                CompatibilityImpact::NonBreaking
1938            },
1939            format!("{path}.validation.min_length"),
1940            "minimum_length_changed",
1941            "minimum accepted length changed",
1942        ));
1943    }
1944}
1945
1946fn compare_max_length(
1947    previous: &[ValidationRule],
1948    current: &[ValidationRule],
1949    path: &str,
1950    changes: &mut Vec<CompatibilityChange>,
1951) {
1952    let old = previous.iter().filter_map(|rule| match rule {
1953        ValidationRule::MaxLength(value) => Some(*value),
1954        ValidationRule::MinLength(_)
1955        | ValidationRule::Email
1956        | ValidationRule::Alias(_)
1957        | ValidationRule::Custom(_)
1958        | ValidationRule::Nested => None,
1959    });
1960    let new = current.iter().filter_map(|rule| match rule {
1961        ValidationRule::MaxLength(value) => Some(*value),
1962        ValidationRule::MinLength(_)
1963        | ValidationRule::Email
1964        | ValidationRule::Alias(_)
1965        | ValidationRule::Custom(_)
1966        | ValidationRule::Nested => None,
1967    });
1968    let old = old.min();
1969    let new = new.min();
1970    if old != new {
1971        let stricter = match (old, new) {
1972            (None, Some(_)) => true,
1973            (Some(old), Some(new)) => new < old,
1974            (Some(_) | None, None) => false,
1975        };
1976        changes.push(change(
1977            if stricter {
1978                CompatibilityImpact::Breaking
1979            } else {
1980                CompatibilityImpact::NonBreaking
1981            },
1982            format!("{path}.validation.max_length"),
1983            "maximum_length_changed",
1984            "maximum accepted length changed",
1985        ));
1986    }
1987}
1988
1989fn compare_string_set(
1990    previous: &[String],
1991    current: &[String],
1992    path: &str,
1993    code_prefix: &str,
1994    added_impact: CompatibilityImpact,
1995    removed_impact: CompatibilityImpact,
1996    changes: &mut Vec<CompatibilityChange>,
1997) {
1998    for old in previous {
1999        if !current.iter().any(|new| new == old) {
2000            changes.push(change(
2001                removed_impact,
2002                format!("{path}.{old}"),
2003                format!("{code_prefix}_removed"),
2004                "a declared value was removed",
2005            ));
2006        }
2007    }
2008    for new in current {
2009        if !previous.iter().any(|old| old == new) {
2010            changes.push(change(
2011                added_impact,
2012                format!("{path}.{new}"),
2013                format!("{code_prefix}_added"),
2014                "a declared value was added",
2015            ));
2016        }
2017    }
2018}
2019
2020fn change(
2021    impact: CompatibilityImpact,
2022    path: impl Into<String>,
2023    code: impl Into<String>,
2024    message: impl Into<String>,
2025) -> CompatibilityChange {
2026    CompatibilityChange::new(impact, path, code, message)
2027}
2028
2029fn same_input_key(left: &InputDescriptor, right: &InputDescriptor) -> bool {
2030    left.source == right.source
2031        && if left.source == InputSource::Header {
2032            left.name.eq_ignore_ascii_case(&right.name)
2033        } else {
2034            left.name == right.name
2035        }
2036}
2037
2038fn input_path(input: &InputDescriptor) -> String {
2039    let name = if input.source == InputSource::Header {
2040        input.name.to_ascii_lowercase()
2041    } else {
2042        input.name.clone()
2043    };
2044    format!("inputs.{}.{}", input_source_name(input.source), name)
2045}
2046
2047fn same_response_key(left: &ResponseDescriptor, right: &ResponseDescriptor) -> bool {
2048    left.status == right.status && left.error_code == right.error_code
2049}
2050
2051fn response_path(response: &ResponseDescriptor) -> String {
2052    match &response.error_code {
2053        Some(code) => format!("responses.{}.{}", response.status, code),
2054        None => format!("responses.{}.success", response.status),
2055    }
2056}
2057
2058const fn input_source_tag(source: InputSource) -> u8 {
2059    match source {
2060        InputSource::Path => 0,
2061        InputSource::Query => 1,
2062        InputSource::Header => 2,
2063        InputSource::Cookie => 3,
2064        InputSource::Json => 4,
2065        InputSource::Form => 5,
2066        InputSource::Multipart => 6,
2067        InputSource::File => 7,
2068        InputSource::Stream => 8,
2069    }
2070}
2071
2072const fn input_source_name(source: InputSource) -> &'static str {
2073    match source {
2074        InputSource::Path => "path",
2075        InputSource::Query => "query",
2076        InputSource::Header => "header",
2077        InputSource::Cookie => "cookie",
2078        InputSource::Json => "json",
2079        InputSource::Form => "form",
2080        InputSource::Multipart => "multipart",
2081        InputSource::File => "file",
2082        InputSource::Stream => "stream",
2083    }
2084}
2085
2086const fn risk_tag(risk: OperationRisk) -> u8 {
2087    match risk {
2088        OperationRisk::Read => 0,
2089        OperationRisk::Write => 1,
2090        OperationRisk::Destructive => 2,
2091    }
2092}
2093
2094const fn confirmation_tag(confirmation: Confirmation) -> u8 {
2095    match confirmation {
2096        Confirmation::Never => 0,
2097        Confirmation::Required => 1,
2098    }
2099}
2100
2101const fn exposure_tag(exposure: OutputExposure) -> u8 {
2102    match exposure {
2103        OutputExposure::Full => 0,
2104        OutputExposure::SummaryOnly => 1,
2105        OutputExposure::None => 2,
2106    }
2107}
2108
2109const fn impact_tag(impact: CompatibilityImpact) -> u8 {
2110    match impact {
2111        CompatibilityImpact::Breaking => 0,
2112        CompatibilityImpact::NonBreaking => 1,
2113        CompatibilityImpact::Metadata => 2,
2114    }
2115}
2116
2117impl From<&str> for TypeDescriptor {
2118    fn from(value: &str) -> Self {
2119        Self::new(value.to_string())
2120    }
2121}
2122
2123#[cfg(test)]
2124mod tests {
2125    use alloc::string::{String, ToString};
2126    use alloc::vec;
2127    use alloc::vec::Vec;
2128
2129    use super::{
2130        AgentPolicy, ApiSchema, Compatibility, Confirmation, DependencyDescriptor, FieldDescriptor,
2131        InputDescriptor, InputSource, McpToolDescriptor, ModelDescriptor, OperationContract,
2132        OperationId, OperationRisk, ResponseDescriptor, ResponseHeader, SchemaKind,
2133        SecurityRequirement, TypeDescriptor, ValidationRule,
2134    };
2135
2136    #[test]
2137    fn operation_ids_are_stable_and_conservative() {
2138        assert!(OperationId::new("users.create-v2").is_ok());
2139        assert!(OperationId::new("").is_err());
2140        assert!(OperationId::new("users/create").is_err());
2141        assert!(OperationId::new("users create").is_err());
2142    }
2143
2144    #[test]
2145    fn canonical_contract_is_independent_of_declaration_order() {
2146        let original = sample_contract();
2147        let mut reordered = original.clone();
2148        reordered.inputs.reverse();
2149        reordered.dependencies.reverse();
2150        reordered.security.reverse();
2151        reordered.security[0].scopes.reverse();
2152        reordered.responses.reverse();
2153        reordered
2154            .responses
2155            .iter_mut()
2156            .find(|response| response.status == 201)
2157            .unwrap()
2158            .headers
2159            .reverse();
2160        let model = reordered.inputs[1].ty.model.as_mut().unwrap();
2161        model.fields.reverse();
2162        model.fields[1].validation.reverse();
2163
2164        assert_eq!(original.canonical_bytes(), reordered.canonical_bytes());
2165        assert_eq!(original.fingerprint(), reordered.fingerprint());
2166    }
2167
2168    #[test]
2169    fn deprecated_input_mirror_does_not_change_fingerprint() {
2170        let original = sample_contract();
2171        let mut changed = original.clone();
2172        changed.input = Some(TypeDescriptor::scalar("bool", SchemaKind::Boolean));
2173
2174        assert_eq!(original.fingerprint(), changed.fingerprint());
2175        assert!(
2176            Compatibility::compare(&original, &changed)
2177                .changes
2178                .is_empty()
2179        );
2180    }
2181
2182    #[test]
2183    fn canonical_fingerprint_has_a_golden_value() {
2184        let fingerprint = sample_contract().fingerprint().to_string();
2185        assert_eq!(
2186            fingerprint,
2187            "blazingly-contract-v1.3-sha256:\
2188             a4164ce1818ddd0509dfdd92d6ef9e72f146d95df073844177ecded466a04381"
2189        );
2190    }
2191
2192    #[test]
2193    fn compatibility_classifies_input_and_response_evolution() {
2194        let original = sample_contract();
2195
2196        let mut optional_input = original.clone();
2197        optional_input.inputs.push(InputDescriptor::new(
2198            "x-request-id",
2199            InputSource::Header,
2200            false,
2201            String::type_descriptor(),
2202        ));
2203        assert!(Compatibility::compare(&original, &optional_input).is_backward_compatible());
2204
2205        let mut required_input = original.clone();
2206        required_input.inputs.push(InputDescriptor::new(
2207            "tenant",
2208            InputSource::Path,
2209            true,
2210            String::type_descriptor(),
2211        ));
2212        let report = Compatibility::compare(&original, &required_input);
2213        assert!(!report.is_backward_compatible());
2214        assert!(
2215            report
2216                .breaking_changes()
2217                .any(|change| change.code == "required_input_added")
2218        );
2219
2220        let mut removed_response = original.clone();
2221        removed_response.responses.pop();
2222        let report = Compatibility::compare(&original, &removed_response);
2223        assert!(
2224            report
2225                .breaking_changes()
2226                .any(|change| change.code == "response_removed")
2227        );
2228    }
2229
2230    #[test]
2231    fn compatibility_catches_stricter_model_agent_and_mcp_policies() {
2232        let original = sample_contract();
2233        let mut stricter = original.clone();
2234        let model = stricter.inputs[0].ty.model.as_mut().unwrap();
2235        model.fields[0]
2236            .validation
2237            .push(ValidationRule::MinLength(12));
2238        stricter.agent.risk = OperationRisk::Destructive;
2239        stricter.agent.confirmation = Confirmation::Required;
2240        stricter.agent.idempotent = false;
2241        stricter.mcp.as_mut().unwrap().expose_output = super::OutputExposure::None;
2242
2243        let report = Compatibility::compare(&original, &stricter);
2244        assert!(!report.is_backward_compatible());
2245        assert!(
2246            report
2247                .breaking_changes()
2248                .any(|change| change.code == "minimum_length_changed")
2249        );
2250        assert!(
2251            report
2252                .breaking_changes()
2253                .any(|change| change.code == "agent_risk_changed")
2254        );
2255        assert!(
2256            report
2257                .breaking_changes()
2258                .any(|change| change.code == "mcp_output_exposure_changed")
2259        );
2260    }
2261
2262    #[test]
2263    fn compatibility_classifies_alias_custom_and_nested_validation() {
2264        let original = sample_contract();
2265
2266        let mut alias_added = original.clone();
2267        alias_added.inputs[0].ty.model.as_mut().unwrap().fields[0]
2268            .validation
2269            .push(ValidationRule::Alias("legacy_name".to_string()));
2270        assert!(Compatibility::compare(&original, &alias_added).is_backward_compatible());
2271        assert!(!Compatibility::compare(&alias_added, &original).is_backward_compatible());
2272
2273        let mut custom_added = original.clone();
2274        custom_added.inputs[0].ty.model.as_mut().unwrap().fields[0]
2275            .validation
2276            .push(ValidationRule::Custom("validate_name".to_string()));
2277        let custom_report = Compatibility::compare(&original, &custom_added);
2278        assert!(
2279            custom_report
2280                .breaking_changes()
2281                .any(|change| change.code == "custom_validator_added")
2282        );
2283
2284        let mut nested_added = original.clone();
2285        nested_added.inputs[0].ty.model.as_mut().unwrap().fields[0]
2286            .validation
2287            .push(ValidationRule::Nested);
2288        let nested_report = Compatibility::compare(&original, &nested_added);
2289        assert!(
2290            nested_report
2291                .breaking_changes()
2292                .any(|change| change.code == "nested_validation_changed")
2293        );
2294    }
2295
2296    #[test]
2297    fn collection_descriptors_retain_nested_model_contracts() {
2298        let descriptor = Vec::<TestModel>::type_descriptor();
2299
2300        assert!(matches!(descriptor.schema, SchemaKind::Array(_)));
2301        assert_eq!(
2302            descriptor
2303                .items
2304                .as_ref()
2305                .and_then(|item| item.model.as_ref())
2306                .map(|model| model.name.as_str()),
2307            Some("TestModel")
2308        );
2309    }
2310
2311    #[test]
2312    fn a_value_types_rules_ride_on_its_type_descriptor() {
2313        let tag = TypeDescriptor::scalar("Tag", SchemaKind::String).with_constraints(vec![
2314            ValidationRule::MinLength(1),
2315            ValidationRule::MaxLength(20),
2316        ]);
2317        let tags = TypeDescriptor {
2318            rust_name: "Vec<Tag>".to_string(),
2319            schema: SchemaKind::Array(alloc::boxed::Box::new(SchemaKind::String)),
2320            model: None,
2321            items: Some(alloc::boxed::Box::new(tag)),
2322            constraints: Vec::new(),
2323        };
2324
2325        assert_eq!(
2326            tags.items.as_ref().map(|item| item.constraints.as_slice()),
2327            Some([ValidationRule::MinLength(1), ValidationRule::MaxLength(20)].as_slice()),
2328            "a collection item keeps the rules its type declared"
2329        );
2330    }
2331
2332    #[test]
2333    fn tightening_a_collection_items_own_rules_is_breaking() {
2334        let original = contract_with_tag_bounds(20);
2335        let tightened = contract_with_tag_bounds(5);
2336
2337        assert_ne!(
2338            original.fingerprint(),
2339            tightened.fingerprint(),
2340            "an item's own bounds are part of the contract identity"
2341        );
2342        let report = Compatibility::compare(&original, &tightened);
2343        assert!(!report.is_backward_compatible());
2344        assert!(
2345            report
2346                .breaking_changes()
2347                .any(|change| change.code == "maximum_length_changed"
2348                    && change.path.contains("items")),
2349            "the finding names the item, not the collection: {:?}",
2350            report.changes
2351        );
2352    }
2353
2354    fn contract_with_tag_bounds(maximum: usize) -> OperationContract {
2355        let tag = TypeDescriptor::scalar("Tag", SchemaKind::String)
2356            .with_constraints(vec![ValidationRule::MaxLength(maximum)]);
2357        let tags = TypeDescriptor {
2358            rust_name: "Vec<Tag>".to_string(),
2359            schema: SchemaKind::Array(alloc::boxed::Box::new(SchemaKind::String)),
2360            model: None,
2361            items: Some(alloc::boxed::Box::new(tag)),
2362            constraints: Vec::new(),
2363        };
2364        OperationContract::new(
2365            "notes.create",
2366            "Create one note",
2367            None,
2368            vec![ResponseDescriptor::success(201, None)],
2369        )
2370        .unwrap()
2371        .with_inputs(vec![InputDescriptor::new(
2372            "body",
2373            InputSource::Json,
2374            true,
2375            TypeDescriptor::model(ModelDescriptor::new(
2376                "CreateNote",
2377                vec![FieldDescriptor::new("tags", true, tags, vec![])],
2378            )),
2379        )])
2380    }
2381
2382    struct TestModel;
2383
2384    impl super::ApiModel for TestModel {
2385        fn model_descriptor() -> ModelDescriptor {
2386            request_model()
2387        }
2388
2389        fn validate(&self) -> Result<(), super::ValidationErrors> {
2390            Ok(())
2391        }
2392    }
2393
2394    fn sample_contract() -> OperationContract {
2395        OperationContract::new(
2396            "users.create",
2397            "Create one user",
2398            None,
2399            vec![
2400                ResponseDescriptor::success(
2401                    201,
2402                    Some(TypeDescriptor::model(ModelDescriptor::new(
2403                        "CreatedUser",
2404                        vec![
2405                            FieldDescriptor::new("id", true, String::type_descriptor(), vec![]),
2406                            FieldDescriptor::new(
2407                                "display_name",
2408                                true,
2409                                String::type_descriptor(),
2410                                vec![],
2411                            ),
2412                        ],
2413                    ))),
2414                )
2415                .with_headers(vec![
2416                    ResponseHeader::new("Location", "/users/{id}"),
2417                    ResponseHeader::new("X-Request-Id", "generated"),
2418                ]),
2419                ResponseDescriptor::error(409, "user_exists", "user already exists", None),
2420            ],
2421        )
2422        .unwrap()
2423        .with_inputs(vec![
2424            InputDescriptor::new(
2425                "body",
2426                InputSource::Json,
2427                true,
2428                TypeDescriptor::model(request_model()),
2429            ),
2430            InputDescriptor::new(
2431                "x-tenant",
2432                InputSource::Header,
2433                true,
2434                String::type_descriptor(),
2435            ),
2436        ])
2437        .with_dependencies(vec![
2438            DependencyDescriptor::new("UserRepository"),
2439            DependencyDescriptor::new("AuditLog"),
2440        ])
2441        .with_security(vec![
2442            SecurityRequirement::new("bearer")
2443                .with_scopes(vec!["users:write".to_string(), "profile:write".to_string()]),
2444            SecurityRequirement::new("tenant"),
2445        ])
2446        .with_agent_policy(AgentPolicy {
2447            risk: OperationRisk::Write,
2448            confirmation: Confirmation::Never,
2449            idempotent: true,
2450        })
2451        .with_mcp_tool(McpToolDescriptor::new(
2452            "users_create",
2453            "Create one user account",
2454        ))
2455    }
2456
2457    fn request_model() -> ModelDescriptor {
2458        ModelDescriptor::new(
2459            "TestModel",
2460            vec![
2461                FieldDescriptor::new(
2462                    "email",
2463                    true,
2464                    String::type_descriptor(),
2465                    vec![ValidationRule::Email, ValidationRule::MinLength(3)],
2466                ),
2467                FieldDescriptor::new(
2468                    "display_name",
2469                    false,
2470                    String::type_descriptor(),
2471                    vec![ValidationRule::MaxLength(80)],
2472                ),
2473            ],
2474        )
2475    }
2476}