Skip to main content

blazingly_contract/
lib.rs

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