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