Skip to main content

icydb_schema/
proposal.rs

1//! Canonical database-scoped proposal envelope.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7
8use crate::{
9    ConstraintFragmentKind, ConstraintSourceKey, EntityFragment, EntitySourceKey,
10    EnumVariantFragment, FieldFragment, FieldSourceKey, FieldType, IndexSourceKey,
11    MAX_SCHEMA_ASSIGNMENTS, MAX_SCHEMA_CAPABILITIES, MAX_SCHEMA_PROPOSAL_FRAGMENTS,
12    MAX_SCHEMA_REMOVALS, NamedTypeFragment, RelationFragment, RelationPathStepFragment,
13    RelationSourceFragment, RelationSourceKey, ScalarLiteral, ScalarType, SchemaContractError,
14    SchemaFragment, SchemaMigrationPlan, SchemaMigrationRename, SchemaMigrationTransform,
15    SchemaProposalDigest, SchemaSubmissionKey, SourceCheckExpr, SourceCheckInstruction,
16    SourceRuleOperation, TargetDatabaseIdentity, TargetStoreIdentity, TargetedRuleFragment,
17    TypeSourceKey, check_len, encode_schema_fragment, encode_schema_proposal,
18};
19
20/// Sole maintained proposal contract version.
21#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
22#[repr(transparent)]
23pub struct ProposalContractVersion(u16);
24
25impl ProposalContractVersion {
26    /// Current pre-1.0 hard-cut proposal contract version.
27    pub const CURRENT: Self = Self(1);
28
29    /// Construct a version token for decoding and incompatibility tests.
30    #[must_use]
31    pub const fn from_raw(value: u16) -> Self {
32        Self(value)
33    }
34
35    /// Return the raw version value.
36    #[must_use]
37    pub const fn get(self) -> u16 {
38        self.0
39    }
40}
41
42/// Feature required by one proposal.
43#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
44#[repr(transparent)]
45pub struct SchemaCapability(u16);
46
47impl SchemaCapability {
48    /// Exact composite record and enum contracts.
49    pub const EXACT_COMPOSITE_TYPES: Self = Self(1);
50    /// Accepted row-local constraints.
51    pub const ACCEPTED_CHECKS: Self = Self(2);
52    /// Secondary indexes.
53    pub const SECONDARY_INDEXES: Self = Self(3);
54    /// Restrictive relations.
55    pub const RESTRICTIVE_RELATIONS: Self = Self(4);
56    /// Accepted database defaults.
57    pub const INSERT_DEFAULTS: Self = Self(5);
58    /// Generated values.
59    pub const GENERATED_VALUES: Self = Self(6);
60    /// Managed created/updated timestamps.
61    pub const MANAGED_TIMESTAMPS: Self = Self(7);
62    /// Explicit versioned source migration declarations.
63    pub const VERSIONED_MIGRATIONS: Self = Self(8);
64
65    /// Construct a raw token for incompatibility testing and transport.
66    #[must_use]
67    pub const fn from_raw(value: u16) -> Self {
68        Self(value)
69    }
70
71    /// Return the raw capability number.
72    #[must_use]
73    pub const fn get(self) -> u16 {
74        self.0
75    }
76
77    const fn is_supported(self) -> bool {
78        matches!(self.0, 1..=8)
79    }
80}
81
82/// Expected accepted-schema head used for optimistic application.
83#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
84#[cfg_attr(not(target_family = "wasm"), derive(ic_wire::Wire))]
85pub enum ExpectedAcceptedHead {
86    /// The target database has no accepted schema.
87    Empty,
88    /// The target must match this exact accepted head.
89    Exact {
90        /// Nonzero accepted-schema revision.
91        revision: u64,
92        /// Opaque accepted-schema fingerprint.
93        fingerprint: crate::ExpectedSchemaFingerprint,
94    },
95}
96
97impl ExpectedAcceptedHead {
98    const fn validate(&self) -> Result<(), SchemaContractError> {
99        match self {
100            Self::Exact { revision: 0, .. } => Err(SchemaContractError::InvalidReferenceList),
101            Self::Empty | Self::Exact { .. } => Ok(()),
102        }
103    }
104}
105
106/// Explicit entity-to-store routing in the target database.
107#[derive(Clone, Debug, Eq, PartialEq)]
108pub struct EntityStoreAssignment {
109    entity: EntitySourceKey,
110    store: TargetStoreIdentity,
111}
112
113impl EntityStoreAssignment {
114    /// Construct one opaque routing assignment.
115    #[must_use]
116    pub const fn new(entity: EntitySourceKey, store: TargetStoreIdentity) -> Self {
117        Self { entity, store }
118    }
119
120    /// Borrow the routed entity source key.
121    #[must_use]
122    pub const fn entity(&self) -> &EntitySourceKey {
123        &self.entity
124    }
125
126    /// Return the opaque target-store identity.
127    #[must_use]
128    pub const fn store(&self) -> TargetStoreIdentity {
129        self.store
130    }
131}
132
133/// Explicit hard-cut removal operation.
134#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
135pub enum SchemaRemoval {
136    /// Remove an entity.
137    Entity(EntitySourceKey),
138    /// Remove one field from an entity.
139    Field {
140        /// Owning entity.
141        entity: EntitySourceKey,
142        /// Field identity.
143        field: FieldSourceKey,
144    },
145    /// Remove a named type.
146    Type(TypeSourceKey),
147    /// Remove one accepted constraint.
148    Constraint {
149        /// Owning entity.
150        entity: EntitySourceKey,
151        /// Constraint identity.
152        constraint: ConstraintSourceKey,
153    },
154    /// Remove one index.
155    Index {
156        /// Owning entity.
157        entity: EntitySourceKey,
158        /// Index identity.
159        index: IndexSourceKey,
160    },
161    /// Remove one relation.
162    Relation {
163        /// Owning entity.
164        entity: EntitySourceKey,
165        /// Relation identity.
166        relation: RelationSourceKey,
167    },
168}
169
170/// Canonical current-form database-scoped proposal.
171#[derive(Clone, Debug, Eq, PartialEq)]
172pub struct SchemaProposal {
173    version: ProposalContractVersion,
174    capabilities: Vec<SchemaCapability>,
175    target_database: TargetDatabaseIdentity,
176    submission_key: SchemaSubmissionKey,
177    expected_head: ExpectedAcceptedHead,
178    fragments: Vec<SchemaFragment>,
179    assignments: Vec<EntityStoreAssignment>,
180    removals: Vec<SchemaRemoval>,
181    migration: Option<SchemaMigrationPlan>,
182}
183
184impl SchemaProposal {
185    /// Compose the public transport form from already selected fragments.
186    ///
187    /// This validates contract-local closure only. IcyDB still treats the
188    /// result as untrusted and resolves target ownership, accepted references,
189    /// capabilities, and catalog-native mutation semantics during application.
190    ///
191    /// # Errors
192    ///
193    /// Returns a typed contract error for bounds, duplicate definitions,
194    /// ambiguous routing, removal conflicts, or malformed nested data.
195    #[expect(
196        clippy::too_many_arguments,
197        clippy::too_many_lines,
198        reason = "composition receives and validates one complete atomic public envelope"
199    )]
200    pub fn try_compose(
201        mut capabilities: Vec<SchemaCapability>,
202        target_database: TargetDatabaseIdentity,
203        submission_key: SchemaSubmissionKey,
204        expected_head: ExpectedAcceptedHead,
205        mut fragments: Vec<SchemaFragment>,
206        mut assignments: Vec<EntityStoreAssignment>,
207        mut removals: Vec<SchemaRemoval>,
208        migration: Option<SchemaMigrationPlan>,
209    ) -> Result<Self, SchemaContractError> {
210        check_len(
211            "proposal capabilities",
212            capabilities.len(),
213            MAX_SCHEMA_CAPABILITIES,
214        )?;
215        check_len(
216            "proposal fragments",
217            fragments.len(),
218            MAX_SCHEMA_PROPOSAL_FRAGMENTS,
219        )?;
220        check_len(
221            "proposal assignments",
222            assignments.len(),
223            MAX_SCHEMA_ASSIGNMENTS,
224        )?;
225        check_len("proposal removals", removals.len(), MAX_SCHEMA_REMOVALS)?;
226        expected_head.validate()?;
227        crate::compact_sort_unstable_by(&mut capabilities, Ord::cmp);
228        ensure_no_adjacent_duplicates(&capabilities)?;
229        if capabilities
230            .iter()
231            .any(|capability| !capability.is_supported())
232        {
233            return Err(SchemaContractError::UnsupportedCapability);
234        }
235        let declares_migration_capability = capabilities
236            .binary_search(&SchemaCapability::VERSIONED_MIGRATIONS)
237            .is_ok();
238        if declares_migration_capability != migration.is_some() {
239            return Err(SchemaContractError::InvalidMigrationPlan);
240        }
241        if let Some(plan) = &migration {
242            plan.validate()?;
243        }
244        for fragment in &fragments {
245            fragment.validate()?;
246        }
247        let mut keyed_fragments = fragments
248            .into_iter()
249            .map(|fragment| encode_schema_fragment(&fragment).map(|bytes| (bytes, fragment)))
250            .collect::<Result<Vec<_>, _>>()?;
251        // Canonically equal fragment bytes describe equal fragments, while
252        // duplicate assignment/removal keys reject below; no stable tie is observable.
253        crate::compact_sort_unstable_by(&mut keyed_fragments, |left, right| left.0.cmp(&right.0));
254        fragments = keyed_fragments
255            .into_iter()
256            .map(|(_, fragment)| fragment)
257            .collect();
258        crate::compact_sort_unstable_by(&mut assignments, |left, right| {
259            left.entity.cmp(&right.entity)
260        });
261        ensure_no_adjacent_duplicates_by(&assignments, |assignment| &assignment.entity)?;
262        crate::compact_sort_unstable_by(&mut removals, Ord::cmp);
263        ensure_no_adjacent_duplicates(&removals)?;
264
265        let mut entity_definitions = BTreeMap::new();
266        let mut type_definitions = BTreeMap::new();
267        let mut field_definitions = BTreeSet::new();
268        let mut constraint_definitions = BTreeSet::new();
269        let mut index_definitions = BTreeSet::new();
270        let mut relation_definitions = BTreeSet::new();
271        let mut entity_names = BTreeSet::new();
272        let mut type_names = BTreeSet::new();
273        for fragment in &fragments {
274            for entity in fragment.entities() {
275                if entity_definitions
276                    .insert(entity.source_key().clone(), entity)
277                    .is_some()
278                {
279                    return Err(SchemaContractError::DuplicateSourceKey);
280                }
281                if !entity_names.insert(entity.name()) {
282                    return Err(SchemaContractError::DuplicateName);
283                }
284                for field in entity.fields() {
285                    field_definitions
286                        .insert((entity.source_key().clone(), field.source_key().clone()));
287                }
288                for constraint in entity.constraints() {
289                    constraint_definitions
290                        .insert((entity.source_key().clone(), constraint.source_key().clone()));
291                }
292                for index in entity.indexes() {
293                    index_definitions
294                        .insert((entity.source_key().clone(), index.source_key().clone()));
295                }
296                for relation in entity.relations() {
297                    relation_definitions
298                        .insert((entity.source_key().clone(), relation.source_key().clone()));
299                }
300            }
301            for r#type in fragment.types() {
302                if type_definitions
303                    .insert(r#type.source_key().clone(), r#type)
304                    .is_some()
305                {
306                    return Err(SchemaContractError::DuplicateSourceKey);
307                }
308                if !type_names.insert(r#type.name()) {
309                    return Err(SchemaContractError::DuplicateName);
310                }
311            }
312        }
313        for assignment in &assignments {
314            if !entity_definitions.contains_key(assignment.entity()) {
315                return Err(SchemaContractError::InvalidReferenceList);
316            }
317        }
318        if assignments.len() != entity_definitions.len() {
319            return Err(SchemaContractError::MissingEntityStoreAssignment);
320        }
321        for removal in &removals {
322            let collides = match removal {
323                SchemaRemoval::Entity(entity) => entity_definitions.contains_key(entity),
324                SchemaRemoval::Field { entity, field } => {
325                    field_definitions.contains(&(entity.clone(), field.clone()))
326                }
327                SchemaRemoval::Type(r#type) => type_definitions.contains_key(r#type),
328                SchemaRemoval::Constraint { entity, constraint } => {
329                    constraint_definitions.contains(&(entity.clone(), constraint.clone()))
330                }
331                SchemaRemoval::Index { entity, index } => {
332                    index_definitions.contains(&(entity.clone(), index.clone()))
333                }
334                SchemaRemoval::Relation { entity, relation } => {
335                    relation_definitions.contains(&(entity.clone(), relation.clone()))
336                }
337            };
338            if collides {
339                return Err(SchemaContractError::DefinitionRemovalConflict);
340            }
341        }
342        validate_proposal_closure(
343            &expected_head,
344            &entity_definitions,
345            &type_definitions,
346            &removals,
347        )?;
348        if let Some(plan) = &migration {
349            validate_migration_plan(plan, &entity_definitions, &type_definitions)?;
350        }
351
352        Ok(Self {
353            version: ProposalContractVersion::CURRENT,
354            capabilities,
355            target_database,
356            submission_key,
357            expected_head,
358            fragments,
359            assignments,
360            removals,
361            migration,
362        })
363    }
364
365    /// Return the contract version.
366    #[must_use]
367    pub const fn version(&self) -> ProposalContractVersion {
368        self.version
369    }
370
371    /// Borrow required capabilities in canonical order.
372    #[must_use]
373    pub fn capabilities(&self) -> &[SchemaCapability] {
374        &self.capabilities
375    }
376
377    /// Return the target database identity.
378    #[must_use]
379    pub const fn target_database(&self) -> TargetDatabaseIdentity {
380        self.target_database
381    }
382
383    /// Borrow the submission key.
384    #[must_use]
385    pub const fn submission_key(&self) -> &SchemaSubmissionKey {
386        &self.submission_key
387    }
388
389    /// Borrow the optimistic accepted-head condition.
390    #[must_use]
391    pub const fn expected_head(&self) -> &ExpectedAcceptedHead {
392        &self.expected_head
393    }
394
395    /// Borrow reusable fragments.
396    #[must_use]
397    pub fn fragments(&self) -> &[SchemaFragment] {
398        &self.fragments
399    }
400
401    /// Borrow canonical entity-to-store assignments.
402    #[must_use]
403    pub fn assignments(&self) -> &[EntityStoreAssignment] {
404        &self.assignments
405    }
406
407    /// Borrow explicit removals.
408    #[must_use]
409    pub fn removals(&self) -> &[SchemaRemoval] {
410        &self.removals
411    }
412
413    /// Borrow the optional coordinated source migration plan.
414    #[must_use]
415    pub const fn migration(&self) -> Option<&SchemaMigrationPlan> {
416        self.migration.as_ref()
417    }
418
419    /// Compute the canonical proposal digest.
420    ///
421    /// # Errors
422    ///
423    /// Returns a typed encoding error if the proposal no longer satisfies the
424    /// current bounded contract.
425    pub fn digest(&self) -> Result<SchemaProposalDigest, SchemaContractError> {
426        let bytes = encode_schema_proposal(self)?;
427        let digest: [u8; 32] = Sha256::digest(bytes).into();
428        Ok(SchemaProposalDigest::from_bytes(digest))
429    }
430
431    pub(crate) fn validate_current(&self) -> Result<(), SchemaContractError> {
432        if self.version != ProposalContractVersion::CURRENT {
433            return Err(SchemaContractError::UnsupportedVersion {
434                found: self.version.get(),
435                supported: ProposalContractVersion::CURRENT.get(),
436            });
437        }
438        let rebuilt = Self::try_compose(
439            self.capabilities.clone(),
440            self.target_database,
441            self.submission_key.clone(),
442            self.expected_head.clone(),
443            self.fragments.clone(),
444            self.assignments.clone(),
445            self.removals.clone(),
446            self.migration.clone(),
447        )?;
448        if rebuilt != *self {
449            return Err(SchemaContractError::NonCanonical);
450        }
451        Ok(())
452    }
453}
454
455pub(crate) fn validate_migration_plan(
456    plan: &SchemaMigrationPlan,
457    entities: &BTreeMap<EntitySourceKey, &EntityFragment>,
458    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
459) -> Result<(), SchemaContractError> {
460    for transition in plan.transitions() {
461        let entity = entities
462            .get(transition.entity())
463            .copied()
464            .ok_or(SchemaContractError::InvalidMigrationReference)?;
465        if transition.from().get().checked_add(1) != Some(entity.version().get()) {
466            return Err(SchemaContractError::MigrationVersionGap);
467        }
468        let reachable = if transition.renames().iter().any(|rename| {
469            matches!(
470                rename,
471                SchemaMigrationRename::NamedType { .. }
472                    | SchemaMigrationRename::EnumVariant { .. }
473                    | SchemaMigrationRename::RecordField { .. }
474            )
475        }) {
476            entity_named_types(entity, types)
477        } else {
478            BTreeSet::new()
479        };
480        for rename in transition.renames() {
481            validate_migration_rename_target(rename, transition, entity, types, &reachable)?;
482        }
483        for transform in transition.transforms() {
484            validate_migration_transform_target(transform, entity, types)?;
485        }
486    }
487    validate_shared_type_transition_closure(plan, entities, types)?;
488    Ok(())
489}
490
491fn validate_shared_type_transition_closure(
492    plan: &SchemaMigrationPlan,
493    entities: &BTreeMap<EntitySourceKey, &EntityFragment>,
494    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
495) -> Result<(), SchemaContractError> {
496    // Deduplicate shared requirements by the same exact key used to canonicalize
497    // declarations. Keep no cross-entity reachability cache: one large shared
498    // graph must not allocate one retained copy per entity.
499    let mut required = BTreeMap::<_, BTreeSet<_>>::new();
500    for transition in plan.transitions() {
501        for rename in transition.renames() {
502            if let Some(target) = shared_rename_target_type(transition, rename) {
503                required
504                    .entry(target)
505                    .or_default()
506                    .insert(rename.sort_key());
507            }
508        }
509    }
510    if required.is_empty() {
511        return Ok(());
512    }
513    for (source, entity) in entities {
514        let transition = plan
515            .transitions()
516            .binary_search_by(|transition| transition.entity().cmp(source))
517            .ok()
518            .map(|index| &plan.transitions()[index]);
519        // Traverse each consumer once, independent of the number of declarations.
520        for target in entity_named_types(entity, types) {
521            let Some(renames) = required.get(target) else {
522                continue;
523            };
524            let transition = transition.ok_or(SchemaContractError::InvalidMigrationReference)?;
525            for rename in renames {
526                if transition
527                    .renames()
528                    .binary_search_by(|candidate| candidate.sort_key().cmp(rename))
529                    .is_err()
530                {
531                    return Err(SchemaContractError::InvalidMigrationReference);
532                }
533            }
534        }
535    }
536    Ok(())
537}
538
539fn shared_rename_target_type(
540    transition: &crate::EntityMigration,
541    rename: &SchemaMigrationRename,
542) -> Option<TypeSourceKey> {
543    match rename {
544        SchemaMigrationRename::NamedType { to, .. } => Some(to.clone()),
545        SchemaMigrationRename::EnumVariant { named_type, .. }
546        | SchemaMigrationRename::RecordField { named_type, .. } => {
547            Some(migration_target_type(transition, named_type))
548        }
549        SchemaMigrationRename::Field { .. }
550        | SchemaMigrationRename::Relation { .. }
551        | SchemaMigrationRename::Constraint { .. }
552        | SchemaMigrationRename::Rule { .. } => None,
553    }
554}
555
556fn validate_migration_rename_target(
557    rename: &SchemaMigrationRename,
558    transition: &crate::EntityMigration,
559    entity: &EntityFragment,
560    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
561    reachable: &BTreeSet<&TypeSourceKey>,
562) -> Result<(), SchemaContractError> {
563    let valid = match rename {
564        SchemaMigrationRename::Field { to, .. } => {
565            entity.fields().iter().any(|field| field.source_key() == to)
566        }
567        SchemaMigrationRename::NamedType { to, .. } => reachable.contains(to),
568        SchemaMigrationRename::EnumVariant { named_type, to, .. } => {
569            let target_type = migration_target_type(transition, named_type);
570            reachable.contains(&target_type)
571                && matches!(
572                    types.get(&target_type),
573                    Some(NamedTypeFragment::Enum(target))
574                        if target.variants().iter().any(|variant| variant.source_key() == to)
575                )
576        }
577        SchemaMigrationRename::RecordField { named_type, to, .. } => {
578            let target_type = migration_target_type(transition, named_type);
579            reachable.contains(&target_type)
580                && matches!(
581                    types.get(&target_type),
582                    Some(NamedTypeFragment::Record(target))
583                        if target.fields().iter().any(|field| field.source_key() == to)
584                )
585        }
586        SchemaMigrationRename::Relation { to, .. } => entity
587            .relations()
588            .iter()
589            .any(|relation| relation.source_key() == to),
590        SchemaMigrationRename::Constraint { to, .. } => entity
591            .constraints()
592            .iter()
593            .any(|constraint| constraint.source_key() == to),
594        SchemaMigrationRename::Rule { named_type, to, .. } => {
595            let target_type = migration_target_type(transition, named_type);
596            entity.constraints().iter().any(|constraint| {
597                matches!(
598                    constraint.kind(),
599                    ConstraintFragmentKind::TargetedRule(rule)
600                        if rule.target_type() == &target_type && rule.rule() == to
601                )
602            })
603        }
604    };
605    if valid {
606        Ok(())
607    } else {
608        Err(SchemaContractError::InvalidMigrationReference)
609    }
610}
611
612fn entity_named_types<'schema>(
613    entity: &'schema EntityFragment,
614    types: &BTreeMap<TypeSourceKey, &'schema NamedTypeFragment>,
615) -> BTreeSet<&'schema TypeSourceKey> {
616    let pending = entity
617        .fields()
618        .iter()
619        .map(FieldFragment::field_type)
620        .collect();
621    let mut reachable = BTreeSet::new();
622    walk_named_field_types(pending, types, &mut reachable, |_| false);
623    // Targeted rules contribute their exact target, not its dependency closure.
624    // This preserves migration ownership independently of field-local rule checks.
625    for constraint in entity.constraints() {
626        if let ConstraintFragmentKind::TargetedRule(rule) = constraint.kind() {
627            reachable.insert(rule.target_type());
628        }
629    }
630    reachable
631}
632
633fn migration_target_type(
634    transition: &crate::EntityMigration,
635    accepted_before: &TypeSourceKey,
636) -> TypeSourceKey {
637    transition
638        .renames()
639        .iter()
640        .find_map(|rename| match rename {
641            SchemaMigrationRename::NamedType { from, to } if from == accepted_before => {
642                Some(to.clone())
643            }
644            _ => None,
645        })
646        .unwrap_or_else(|| accepted_before.clone())
647}
648
649fn validate_migration_transform_target(
650    transform: &SchemaMigrationTransform,
651    entity: &EntityFragment,
652    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
653) -> Result<(), SchemaContractError> {
654    let target = entity
655        .fields()
656        .iter()
657        .find(|field| field.source_key() == transform.target())
658        .ok_or(SchemaContractError::InvalidMigrationReference)?;
659    match transform {
660        SchemaMigrationTransform::Fill { literal, .. }
661        | SchemaMigrationTransform::Coalesce { literal, .. } => {
662            validate_migration_literal_target(literal, target, types)
663        }
664        SchemaMigrationTransform::CheckedCast {
665            target: target_scalar,
666            ..
667        } if target.field_type() == &FieldType::Scalar(*target_scalar) => Ok(()),
668        SchemaMigrationTransform::Copy { .. } => Ok(()),
669        SchemaMigrationTransform::CheckedCast { .. } => {
670            Err(SchemaContractError::InvalidMigrationTransform)
671        }
672    }
673}
674
675fn validate_migration_literal_target(
676    literal: &ScalarLiteral,
677    target: &FieldFragment,
678    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
679) -> Result<(), SchemaContractError> {
680    let valid = match (target.field_type(), literal) {
681        (FieldType::Scalar(scalar), literal) => scalar.accepts_literal(literal),
682        (FieldType::Named(target_type), ScalarLiteral::EnumUnit { enum_type, variant })
683            if target_type == enum_type =>
684        {
685            matches!(
686                types.get(enum_type),
687                Some(NamedTypeFragment::Enum(target_enum))
688                    if target_enum
689                        .variants()
690                        .iter()
691                        .any(|candidate| candidate.source_key() == variant)
692            )
693        }
694        (FieldType::List(_) | FieldType::Named(_), _) => false,
695    };
696    if valid {
697        Ok(())
698    } else {
699        Err(SchemaContractError::LiteralTypeMismatch)
700    }
701}
702
703#[derive(Default)]
704struct ProposalReferences {
705    types: BTreeSet<TypeSourceKey>,
706    relation_entities: BTreeSet<EntitySourceKey>,
707    relation_fields: BTreeSet<(EntitySourceKey, FieldSourceKey)>,
708}
709
710pub(crate) fn validate_proposal_closure(
711    expected_head: &ExpectedAcceptedHead,
712    entities: &BTreeMap<EntitySourceKey, &EntityFragment>,
713    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
714    removals: &[SchemaRemoval],
715) -> Result<(), SchemaContractError> {
716    let mut references = ProposalReferences::default();
717    for entity in entities.values() {
718        collect_entity_references(entity, types, &mut references)?;
719        validate_local_relation_targets(entity, entities, types)?;
720    }
721    for r#type in types.values() {
722        collect_named_type_references(r#type, &mut references);
723    }
724    for removal in removals {
725        let removes_reference = match removal {
726            SchemaRemoval::Entity(entity) => references.relation_entities.contains(entity),
727            SchemaRemoval::Field { entity, field } => references
728                .relation_fields
729                .contains(&(entity.clone(), field.clone())),
730            SchemaRemoval::Type(r#type) => references.types.contains(r#type),
731            SchemaRemoval::Constraint { .. }
732            | SchemaRemoval::Index { .. }
733            | SchemaRemoval::Relation { .. } => false,
734        };
735        if removes_reference {
736            return Err(SchemaContractError::RemovedReference);
737        }
738    }
739    if matches!(expected_head, ExpectedAcceptedHead::Empty)
740        && (references
741            .types
742            .iter()
743            .any(|reference| !types.contains_key(reference))
744            || references
745                .relation_entities
746                .iter()
747                .any(|reference| !entities.contains_key(reference)))
748    {
749        return Err(SchemaContractError::InvalidLocalReference);
750    }
751    Ok(())
752}
753
754fn collect_entity_references(
755    entity: &EntityFragment,
756    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
757    references: &mut ProposalReferences,
758) -> Result<(), SchemaContractError> {
759    for field in entity.fields() {
760        collect_field_references(field, types, references)?;
761    }
762    for relation in entity.relations() {
763        references
764            .relation_entities
765            .insert(relation.target_entity().clone());
766        references.relation_fields.extend(
767            relation
768                .target_fields()
769                .iter()
770                .cloned()
771                .map(|field| (relation.target_entity().clone(), field)),
772        );
773        if let RelationSourceFragment::Nested { steps, .. } = relation.source() {
774            references
775                .types
776                .extend(steps.iter().filter_map(|step| match step {
777                    RelationPathStepFragment::EnterNamed { r#type } => Some(r#type.clone()),
778                    RelationPathStepFragment::RecordMember { record, .. } => Some(record.clone()),
779                    RelationPathStepFragment::EnumVariantPayload { r#enum, .. } => {
780                        Some(r#enum.clone())
781                    }
782                    RelationPathStepFragment::OptionalSome
783                    | RelationPathStepFragment::ListItems
784                    | RelationPathStepFragment::SetItems
785                    | RelationPathStepFragment::MapValues => None,
786                }));
787        }
788    }
789    for index in entity.indexes() {
790        if let Some(predicate) = index.predicate() {
791            collect_expression_enum_references(predicate, types, references)?;
792        }
793    }
794    for constraint in entity.constraints() {
795        match constraint.kind() {
796            ConstraintFragmentKind::Check(expression) => {
797                collect_expression_enum_references(expression, types, references)?;
798            }
799            ConstraintFragmentKind::TargetedRule(rule) => {
800                references.types.insert(rule.target_type().clone());
801                validate_targeted_rule(entity, rule, types)?;
802            }
803        }
804    }
805    Ok(())
806}
807
808fn validate_targeted_rule(
809    entity: &EntityFragment,
810    rule: &TargetedRuleFragment,
811    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
812) -> Result<(), SchemaContractError> {
813    let root = entity
814        .fields()
815        .iter()
816        .find(|field| field.source_key() == rule.root())
817        .ok_or(SchemaContractError::InvalidLocalReference)?;
818    if types.contains_key(rule.target_type())
819        && !field_type_reaches_target(root.field_type(), rule.target_type(), types)
820    {
821        return Err(SchemaContractError::InvalidRuleTarget);
822    }
823    let Some(target) = types.get(rule.target_type()) else {
824        return Ok(());
825    };
826    let shape = resolve_rule_target_shape(target, types)?;
827    if operation_matches_target(rule.operation(), shape) {
828        Ok(())
829    } else {
830        Err(SchemaContractError::InvalidRuleTarget)
831    }
832}
833
834fn field_type_reaches_target(
835    root: &FieldType,
836    target: &TypeSourceKey,
837    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
838) -> bool {
839    walk_named_field_types(vec![root], types, &mut BTreeSet::new(), |source| {
840        source == target
841    })
842}
843
844// Visit each named source once and permit target searches to stop before
845// expanding it. Collection callers retain the same visited set as their result.
846fn walk_named_field_types<'a>(
847    mut pending: Vec<&'a FieldType>,
848    types: &BTreeMap<TypeSourceKey, &'a NamedTypeFragment>,
849    visited: &mut BTreeSet<&'a TypeSourceKey>,
850    mut stop_at: impl FnMut(&'a TypeSourceKey) -> bool,
851) -> bool {
852    while let Some(field_type) = pending.pop() {
853        match field_type {
854            FieldType::Scalar(_) => {}
855            FieldType::List(item) => pending.push(item),
856            FieldType::Named(source) => {
857                if !visited.insert(source) {
858                    continue;
859                }
860                if stop_at(source) {
861                    return true;
862                }
863                if let Some(definition) = types.get(source) {
864                    push_named_type_field_types(definition, &mut pending);
865                }
866            }
867        }
868    }
869    false
870}
871
872fn push_named_type_field_types<'types>(
873    r#type: &'types NamedTypeFragment,
874    pending: &mut Vec<&'types FieldType>,
875) {
876    match r#type {
877        NamedTypeFragment::Record(record) => {
878            pending.extend(
879                record
880                    .fields()
881                    .iter()
882                    .map(crate::RecordFieldFragment::field_type),
883            );
884        }
885        NamedTypeFragment::Enum(r#enum) => {
886            pending.extend(
887                r#enum
888                    .variants()
889                    .iter()
890                    .filter_map(|variant| variant.payload()),
891            );
892        }
893        NamedTypeFragment::Newtype { inner, .. }
894        | NamedTypeFragment::List { item: inner, .. }
895        | NamedTypeFragment::Set { item: inner, .. } => pending.push(inner),
896        NamedTypeFragment::Map { key, value, .. } => {
897            pending.push(key);
898            pending.push(value);
899        }
900        NamedTypeFragment::Tuple { members, .. } => {
901            pending.extend(members.iter().map(crate::TupleElementFragment::field_type));
902        }
903    }
904}
905
906#[derive(Clone, Copy)]
907enum RuleTargetShape {
908    Collection,
909    Scalar(ScalarType),
910}
911
912fn resolve_rule_target_shape(
913    target: &NamedTypeFragment,
914    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
915) -> Result<RuleTargetShape, SchemaContractError> {
916    let mut current = target;
917    let mut visited = BTreeSet::new();
918    loop {
919        if !visited.insert(current.source_key()) {
920            return Err(SchemaContractError::InvalidRuleTarget);
921        }
922        match current {
923            NamedTypeFragment::List { .. }
924            | NamedTypeFragment::Set { .. }
925            | NamedTypeFragment::Map { .. } => return Ok(RuleTargetShape::Collection),
926            NamedTypeFragment::Newtype { inner, .. } => match inner {
927                FieldType::Scalar(scalar) => return Ok(RuleTargetShape::Scalar(*scalar)),
928                FieldType::List(_) => return Ok(RuleTargetShape::Collection),
929                FieldType::Named(source) => {
930                    current = types
931                        .get(source)
932                        .copied()
933                        .ok_or(SchemaContractError::InvalidRuleTarget)?;
934                }
935            },
936            NamedTypeFragment::Record(_)
937            | NamedTypeFragment::Enum(_)
938            | NamedTypeFragment::Tuple { .. } => {
939                return Err(SchemaContractError::InvalidRuleTarget);
940            }
941        }
942    }
943}
944
945fn operation_matches_target(operation: &SourceRuleOperation, shape: RuleTargetShape) -> bool {
946    match (operation, shape) {
947        (
948            SourceRuleOperation::LengthRangeInclusive { .. },
949            RuleTargetShape::Collection
950            | RuleTargetShape::Scalar(ScalarType::Blob { .. } | ScalarType::Text { .. }),
951        ) => true,
952        (
953            SourceRuleOperation::NumericMaximumInclusive { value }
954            | SourceRuleOperation::NumericMinimumInclusive { value },
955            RuleTargetShape::Scalar(scalar),
956        ) => numeric_scalar(scalar) && numeric_rule_literal_matches(scalar, value),
957        (
958            SourceRuleOperation::NumericRangeInclusive { min, max },
959            RuleTargetShape::Scalar(scalar),
960        ) => {
961            numeric_scalar(scalar)
962                && numeric_rule_literal_matches(scalar, min)
963                && numeric_rule_literal_matches(scalar, max)
964        }
965        (SourceRuleOperation::MultipleOf { divisor }, RuleTargetShape::Scalar(scalar)) => {
966            exact_numeric_scalar(scalar) && numeric_rule_literal_matches(scalar, divisor)
967        }
968        _ => false,
969    }
970}
971
972fn numeric_rule_literal_matches(scalar: ScalarType, literal: &crate::ScalarLiteral) -> bool {
973    if let (ScalarType::Decimal { scale }, crate::ScalarLiteral::Decimal(value)) = (scalar, literal)
974    {
975        let value = value.normalize();
976        return value.scale() <= scale && value.scale_to_integer(scale).is_some();
977    }
978    scalar.accepts_literal(literal)
979}
980
981const fn exact_numeric_scalar(scalar: ScalarType) -> bool {
982    matches!(
983        scalar,
984        ScalarType::Decimal { .. }
985            | ScalarType::Int8
986            | ScalarType::Int16
987            | ScalarType::Int32
988            | ScalarType::Int64
989            | ScalarType::Int128
990            | ScalarType::Nat8
991            | ScalarType::Nat16
992            | ScalarType::Nat32
993            | ScalarType::Nat64
994            | ScalarType::Nat128
995    )
996}
997
998const fn numeric_scalar(scalar: ScalarType) -> bool {
999    exact_numeric_scalar(scalar)
1000        || matches!(
1001            scalar,
1002            ScalarType::Float32 | ScalarType::Float64 | ScalarType::U256
1003        )
1004}
1005
1006fn collect_named_type_references(r#type: &NamedTypeFragment, references: &mut ProposalReferences) {
1007    match r#type {
1008        NamedTypeFragment::Record(record) => {
1009            for field in record.fields() {
1010                collect_field_type_reference(field.field_type(), references);
1011            }
1012        }
1013        NamedTypeFragment::Enum(r#enum) => {
1014            for variant in r#enum.variants() {
1015                if let Some(payload) = variant.payload() {
1016                    collect_field_type_reference(payload, references);
1017                }
1018            }
1019        }
1020        NamedTypeFragment::Newtype { inner, .. }
1021        | NamedTypeFragment::List { item: inner, .. }
1022        | NamedTypeFragment::Set { item: inner, .. } => {
1023            collect_field_type_reference(inner, references);
1024        }
1025        NamedTypeFragment::Map { key, value, .. } => {
1026            collect_field_type_reference(key, references);
1027            collect_field_type_reference(value, references);
1028        }
1029        NamedTypeFragment::Tuple { members, .. } => {
1030            for member in members {
1031                collect_field_type_reference(member.field_type(), references);
1032            }
1033        }
1034    }
1035}
1036
1037#[cfg(test)]
1038mod u256_tests {
1039    use super::*;
1040    use crate::{ScalarLiteral, U256};
1041
1042    #[test]
1043    fn u256_targeted_ranges_admit_while_multiple_of_remains_deferred() {
1044        let shape = RuleTargetShape::Scalar(ScalarType::U256);
1045        assert!(operation_matches_target(
1046            &SourceRuleOperation::NumericRangeInclusive {
1047                min: ScalarLiteral::U256(U256::ZERO),
1048                max: ScalarLiteral::U256(U256::MAX),
1049            },
1050            shape,
1051        ));
1052        assert!(!operation_matches_target(
1053            &SourceRuleOperation::MultipleOf {
1054                divisor: ScalarLiteral::U256(U256::ONE),
1055            },
1056            shape,
1057        ));
1058    }
1059}
1060
1061fn collect_field_references(
1062    field: &FieldFragment,
1063    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
1064    references: &mut ProposalReferences,
1065) -> Result<(), SchemaContractError> {
1066    collect_field_type_reference(field.field_type(), references);
1067    if let crate::FieldInsertPolicy::Default(ScalarLiteral::EnumUnit { enum_type, variant }) =
1068        field.insert_policy()
1069    {
1070        let FieldType::Named(field_type) = field.field_type() else {
1071            return Err(SchemaContractError::LiteralTypeMismatch);
1072        };
1073        if field_type != enum_type {
1074            return Err(SchemaContractError::LiteralTypeMismatch);
1075        }
1076        collect_enum_literal_reference(enum_type, variant, types, references)?;
1077    }
1078    Ok(())
1079}
1080
1081fn collect_field_type_reference(field_type: &FieldType, references: &mut ProposalReferences) {
1082    match field_type {
1083        FieldType::List(item) => collect_field_type_reference(item, references),
1084        FieldType::Named(reference) => {
1085            references.types.insert(reference.clone());
1086        }
1087        FieldType::Scalar(_) => {}
1088    }
1089}
1090
1091fn collect_expression_enum_references(
1092    expression: &SourceCheckExpr,
1093    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
1094    references: &mut ProposalReferences,
1095) -> Result<(), SchemaContractError> {
1096    for instruction in expression.instructions() {
1097        if let SourceCheckInstruction::Literal(ScalarLiteral::EnumUnit { enum_type, variant }) =
1098            instruction
1099        {
1100            collect_enum_literal_reference(enum_type, variant, types, references)?;
1101        }
1102    }
1103    Ok(())
1104}
1105
1106fn collect_enum_literal_reference(
1107    enum_type: &TypeSourceKey,
1108    variant: &TypeSourceKey,
1109    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
1110    references: &mut ProposalReferences,
1111) -> Result<(), SchemaContractError> {
1112    references.types.insert(enum_type.clone());
1113    let Some(local) = types.get(enum_type) else {
1114        return Ok(());
1115    };
1116    let NamedTypeFragment::Enum(local) = local else {
1117        return Err(SchemaContractError::InvalidEnumLiteral);
1118    };
1119    if local
1120        .variants()
1121        .iter()
1122        .all(|candidate| candidate.source_key() != variant)
1123    {
1124        return Err(SchemaContractError::InvalidEnumLiteral);
1125    }
1126    Ok(())
1127}
1128
1129fn validate_local_relation_targets(
1130    source: &EntityFragment,
1131    entities: &BTreeMap<EntitySourceKey, &EntityFragment>,
1132    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
1133) -> Result<(), SchemaContractError> {
1134    for relation in source.relations() {
1135        let Some(target) = entities.get(relation.target_entity()) else {
1136            continue;
1137        };
1138        match relation.source() {
1139            RelationSourceFragment::Direct { fields } => {
1140                for (source_key, target_key) in fields.iter().zip(relation.target_fields()) {
1141                    let source_field = source
1142                        .fields()
1143                        .iter()
1144                        .find(|field| field.source_key() == source_key)
1145                        .ok_or(SchemaContractError::InvalidLocalReference)?;
1146                    let target_field = target
1147                        .fields()
1148                        .iter()
1149                        .find(|field| field.source_key() == target_key)
1150                        .ok_or(SchemaContractError::InvalidLocalReference)?;
1151                    let source_type = match source_field.field_type() {
1152                        FieldType::List(item) => item.as_ref(),
1153                        field_type => field_type,
1154                    };
1155                    if source_type != target_field.field_type() {
1156                        return Err(SchemaContractError::RelationTypeMismatch);
1157                    }
1158                }
1159            }
1160            RelationSourceFragment::Nested { .. } => {
1161                let terminal = validate_nested_relation_path(source, relation, types)?;
1162                let target_key = relation
1163                    .target_fields()
1164                    .first()
1165                    .ok_or(SchemaContractError::InvalidReferenceList)?;
1166                let target_field = target
1167                    .fields()
1168                    .iter()
1169                    .find(|field| field.source_key() == target_key)
1170                    .ok_or(SchemaContractError::InvalidLocalReference)?;
1171                if terminal != target_field.field_type() {
1172                    return Err(SchemaContractError::RelationTypeMismatch);
1173                }
1174            }
1175        }
1176    }
1177    Ok(())
1178}
1179
1180fn validate_nested_relation_path<'schema>(
1181    source: &'schema EntityFragment,
1182    relation: &RelationFragment,
1183    types: &BTreeMap<TypeSourceKey, &'schema NamedTypeFragment>,
1184) -> Result<&'schema FieldType, SchemaContractError> {
1185    let RelationSourceFragment::Nested { root, steps } = relation.source() else {
1186        return Err(SchemaContractError::RelationTypeMismatch);
1187    };
1188    let root = source
1189        .fields()
1190        .iter()
1191        .find(|field| field.source_key() == root)
1192        .ok_or(SchemaContractError::InvalidLocalReference)?;
1193    let mut kind = root.field_type();
1194    let mut nullable = root.nullable();
1195    let mut entered = None;
1196    for step in steps {
1197        match step {
1198            RelationPathStepFragment::OptionalSome => {
1199                if !nullable || entered.is_some() {
1200                    return Err(SchemaContractError::RelationTypeMismatch);
1201                }
1202                nullable = false;
1203            }
1204            RelationPathStepFragment::EnterNamed { r#type } => {
1205                let FieldType::Named(named) = kind else {
1206                    return Err(SchemaContractError::RelationTypeMismatch);
1207                };
1208                if nullable || entered.is_some() || named != r#type {
1209                    return Err(SchemaContractError::RelationTypeMismatch);
1210                }
1211                match types.get(named).copied() {
1212                    Some(
1213                        NamedTypeFragment::Record(_)
1214                        | NamedTypeFragment::Enum(_)
1215                        | NamedTypeFragment::List { .. }
1216                        | NamedTypeFragment::Set { .. }
1217                        | NamedTypeFragment::Map { .. },
1218                    ) => {
1219                        entered = Some(named);
1220                    }
1221                    Some(NamedTypeFragment::Newtype { inner, .. }) => {
1222                        kind = inner;
1223                    }
1224                    Some(NamedTypeFragment::Tuple { .. }) | None => {
1225                        return Err(SchemaContractError::RelationTypeMismatch);
1226                    }
1227                }
1228            }
1229            RelationPathStepFragment::RecordMember { record, field } => {
1230                if nullable || entered != Some(record) {
1231                    return Err(SchemaContractError::RelationTypeMismatch);
1232                }
1233                let Some(NamedTypeFragment::Record(definition)) = types.get(record).copied() else {
1234                    return Err(SchemaContractError::RelationTypeMismatch);
1235                };
1236                let member = definition
1237                    .fields()
1238                    .iter()
1239                    .find(|member| member.source_key() == field)
1240                    .ok_or(SchemaContractError::InvalidLocalReference)?;
1241                kind = member.field_type();
1242                nullable = member.nullable();
1243                entered = None;
1244            }
1245            RelationPathStepFragment::EnumVariantPayload { r#enum, variant } => {
1246                if nullable || entered != Some(r#enum) {
1247                    return Err(SchemaContractError::RelationTypeMismatch);
1248                }
1249                let Some(NamedTypeFragment::Enum(definition)) = types.get(r#enum).copied() else {
1250                    return Err(SchemaContractError::RelationTypeMismatch);
1251                };
1252                let payload = definition
1253                    .variants()
1254                    .iter()
1255                    .find(|candidate| candidate.source_key() == variant)
1256                    .and_then(EnumVariantFragment::payload)
1257                    .ok_or(SchemaContractError::InvalidLocalReference)?;
1258                kind = payload;
1259                nullable = false;
1260                entered = None;
1261            }
1262            RelationPathStepFragment::ListItems
1263            | RelationPathStepFragment::SetItems
1264            | RelationPathStepFragment::MapValues => {
1265                if nullable {
1266                    return Err(SchemaContractError::RelationTypeMismatch);
1267                }
1268                kind = relation_collection_step_output(kind, entered.take(), step, types)?;
1269                nullable = false;
1270            }
1271        }
1272    }
1273    if nullable || entered.is_some() || !matches!(kind, FieldType::Scalar(_)) {
1274        return Err(SchemaContractError::RelationTypeMismatch);
1275    }
1276    Ok(kind)
1277}
1278
1279fn relation_collection_step_output<'schema>(
1280    kind: &'schema FieldType,
1281    entered: Option<&TypeSourceKey>,
1282    step: &RelationPathStepFragment,
1283    types: &BTreeMap<TypeSourceKey, &'schema NamedTypeFragment>,
1284) -> Result<&'schema FieldType, SchemaContractError> {
1285    match (step, entered, kind) {
1286        (RelationPathStepFragment::ListItems, None, FieldType::List(item)) => Ok(item),
1287        (RelationPathStepFragment::ListItems, Some(named), FieldType::Named(current))
1288            if named == current =>
1289        {
1290            let Some(NamedTypeFragment::List { item, .. }) = types.get(current).copied() else {
1291                return Err(SchemaContractError::RelationTypeMismatch);
1292            };
1293            Ok(item)
1294        }
1295        (RelationPathStepFragment::SetItems, Some(named), FieldType::Named(current))
1296            if named == current =>
1297        {
1298            let Some(NamedTypeFragment::Set { item, .. }) = types.get(current).copied() else {
1299                return Err(SchemaContractError::RelationTypeMismatch);
1300            };
1301            Ok(item)
1302        }
1303        (RelationPathStepFragment::MapValues, Some(named), FieldType::Named(current))
1304            if named == current =>
1305        {
1306            let Some(NamedTypeFragment::Map { value, .. }) = types.get(current).copied() else {
1307                return Err(SchemaContractError::RelationTypeMismatch);
1308            };
1309            Ok(value)
1310        }
1311        _ => Err(SchemaContractError::RelationTypeMismatch),
1312    }
1313}
1314
1315fn ensure_no_adjacent_duplicates<T>(values: &[T]) -> Result<(), SchemaContractError>
1316where
1317    T: Eq,
1318{
1319    if values.windows(2).any(|pair| pair[0] == pair[1]) {
1320        return Err(SchemaContractError::DuplicateSourceKey);
1321    }
1322    Ok(())
1323}
1324
1325fn ensure_no_adjacent_duplicates_by<T, K>(
1326    values: &[T],
1327    key: impl Fn(&T) -> &K,
1328) -> Result<(), SchemaContractError>
1329where
1330    K: Eq,
1331{
1332    if values.windows(2).any(|pair| key(&pair[0]) == key(&pair[1])) {
1333        return Err(SchemaContractError::DuplicateSourceKey);
1334    }
1335    Ok(())
1336}
1337
1338#[cfg(test)]
1339mod tests {
1340    use super::*;
1341    use crate::decode_schema_proposal;
1342    use std::str::FromStr;
1343
1344    fn empty_proposal_with(capabilities: Vec<SchemaCapability>) -> SchemaProposal {
1345        SchemaProposal::try_compose(
1346            capabilities,
1347            TargetDatabaseIdentity::from_bytes([1; 32]),
1348            SchemaSubmissionKey::try_new("proposal-version-test")
1349                .expect("submission key should admit"),
1350            ExpectedAcceptedHead::Empty,
1351            Vec::new(),
1352            Vec::new(),
1353            Vec::new(),
1354            None,
1355        )
1356        .expect("empty proposal should compose")
1357    }
1358
1359    #[test]
1360    fn decoded_future_contract_version_fails_typed() {
1361        let proposal = empty_proposal_with(Vec::new());
1362        let mut bytes = crate::encode_schema_proposal(&proposal).expect("proposal should encode");
1363        bytes[5..7].copy_from_slice(&2_u16.to_be_bytes());
1364
1365        assert_eq!(
1366            decode_schema_proposal(&bytes),
1367            Err(SchemaContractError::UnsupportedVersion {
1368                found: 2,
1369                supported: 1,
1370            }),
1371        );
1372    }
1373
1374    #[test]
1375    fn decoded_unknown_capability_fails_typed() {
1376        let proposal = empty_proposal_with(vec![SchemaCapability::EXACT_COMPOSITE_TYPES]);
1377        let mut bytes = crate::encode_schema_proposal(&proposal).expect("proposal should encode");
1378        bytes[11..13].copy_from_slice(&u16::MAX.to_be_bytes());
1379
1380        assert_eq!(
1381            decode_schema_proposal(&bytes),
1382            Err(SchemaContractError::UnsupportedCapability),
1383        );
1384    }
1385
1386    #[test]
1387    fn targeted_numeric_operations_require_exact_target_literal_admission() {
1388        let decimal = |value| {
1389            crate::ScalarLiteral::Decimal(
1390                crate::Decimal::from_str(value).expect("decimal fixture should parse"),
1391            )
1392        };
1393        assert!(operation_matches_target(
1394            &SourceRuleOperation::MultipleOf {
1395                divisor: decimal("0.25"),
1396            },
1397            RuleTargetShape::Scalar(ScalarType::Decimal { scale: 2 }),
1398        ));
1399        assert!(!operation_matches_target(
1400            &SourceRuleOperation::MultipleOf {
1401                divisor: decimal("0.251"),
1402            },
1403            RuleTargetShape::Scalar(ScalarType::Decimal { scale: 2 }),
1404        ));
1405        assert!(!operation_matches_target(
1406            &SourceRuleOperation::MultipleOf {
1407                divisor: crate::ScalarLiteral::Float64(
1408                    crate::Float64::try_new(2.0).expect("finite float"),
1409                ),
1410            },
1411            RuleTargetShape::Scalar(ScalarType::Float64),
1412        ));
1413        assert!(operation_matches_target(
1414            &SourceRuleOperation::NumericMaximumInclusive {
1415                value: crate::ScalarLiteral::Float64(
1416                    crate::Float64::try_new(2.0).expect("finite float"),
1417                ),
1418            },
1419            RuleTargetShape::Scalar(ScalarType::Float64),
1420        ));
1421    }
1422}