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 mut pending: Vec<_> = entity
617        .fields()
618        .iter()
619        .map(FieldFragment::field_type)
620        .collect();
621    let mut reachable = BTreeSet::new();
622    while let Some(field_type) = pending.pop() {
623        match field_type {
624            FieldType::Scalar(_) => {}
625            FieldType::List(item) => pending.push(item),
626            FieldType::Named(source) => {
627                if reachable.insert(source)
628                    && let Some(definition) = types.get(source)
629                {
630                    push_named_type_field_types(definition, &mut pending);
631                }
632            }
633        }
634    }
635    // Targeted rules contribute their exact target, not its dependency closure.
636    // This preserves migration ownership independently of field-local rule checks.
637    for constraint in entity.constraints() {
638        if let ConstraintFragmentKind::TargetedRule(rule) = constraint.kind() {
639            reachable.insert(rule.target_type());
640        }
641    }
642    reachable
643}
644
645fn migration_target_type(
646    transition: &crate::EntityMigration,
647    accepted_before: &TypeSourceKey,
648) -> TypeSourceKey {
649    transition
650        .renames()
651        .iter()
652        .find_map(|rename| match rename {
653            SchemaMigrationRename::NamedType { from, to } if from == accepted_before => {
654                Some(to.clone())
655            }
656            _ => None,
657        })
658        .unwrap_or_else(|| accepted_before.clone())
659}
660
661fn validate_migration_transform_target(
662    transform: &SchemaMigrationTransform,
663    entity: &EntityFragment,
664    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
665) -> Result<(), SchemaContractError> {
666    let target = entity
667        .fields()
668        .iter()
669        .find(|field| field.source_key() == transform.target())
670        .ok_or(SchemaContractError::InvalidMigrationReference)?;
671    match transform {
672        SchemaMigrationTransform::Fill { literal, .. }
673        | SchemaMigrationTransform::Coalesce { literal, .. } => {
674            validate_migration_literal_target(literal, target, types)
675        }
676        SchemaMigrationTransform::CheckedCast {
677            target: target_scalar,
678            ..
679        } if target.field_type() == &FieldType::Scalar(*target_scalar) => Ok(()),
680        SchemaMigrationTransform::Copy { .. } => Ok(()),
681        SchemaMigrationTransform::CheckedCast { .. } => {
682            Err(SchemaContractError::InvalidMigrationTransform)
683        }
684    }
685}
686
687fn validate_migration_literal_target(
688    literal: &ScalarLiteral,
689    target: &FieldFragment,
690    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
691) -> Result<(), SchemaContractError> {
692    let valid = match (target.field_type(), literal) {
693        (FieldType::Scalar(scalar), literal) => scalar.accepts_literal(literal),
694        (FieldType::Named(target_type), ScalarLiteral::EnumUnit { enum_type, variant })
695            if target_type == enum_type =>
696        {
697            matches!(
698                types.get(enum_type),
699                Some(NamedTypeFragment::Enum(target_enum))
700                    if target_enum
701                        .variants()
702                        .iter()
703                        .any(|candidate| candidate.source_key() == variant)
704            )
705        }
706        (FieldType::List(_) | FieldType::Named(_), _) => false,
707    };
708    if valid {
709        Ok(())
710    } else {
711        Err(SchemaContractError::LiteralTypeMismatch)
712    }
713}
714
715#[derive(Default)]
716struct ProposalReferences {
717    types: BTreeSet<TypeSourceKey>,
718    relation_entities: BTreeSet<EntitySourceKey>,
719    relation_fields: BTreeSet<(EntitySourceKey, FieldSourceKey)>,
720}
721
722pub(crate) fn validate_proposal_closure(
723    expected_head: &ExpectedAcceptedHead,
724    entities: &BTreeMap<EntitySourceKey, &EntityFragment>,
725    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
726    removals: &[SchemaRemoval],
727) -> Result<(), SchemaContractError> {
728    let mut references = ProposalReferences::default();
729    for entity in entities.values() {
730        collect_entity_references(entity, types, &mut references)?;
731        validate_local_relation_targets(entity, entities, types)?;
732    }
733    for r#type in types.values() {
734        collect_named_type_references(r#type, &mut references);
735    }
736    for removal in removals {
737        let removes_reference = match removal {
738            SchemaRemoval::Entity(entity) => references.relation_entities.contains(entity),
739            SchemaRemoval::Field { entity, field } => references
740                .relation_fields
741                .contains(&(entity.clone(), field.clone())),
742            SchemaRemoval::Type(r#type) => references.types.contains(r#type),
743            SchemaRemoval::Constraint { .. }
744            | SchemaRemoval::Index { .. }
745            | SchemaRemoval::Relation { .. } => false,
746        };
747        if removes_reference {
748            return Err(SchemaContractError::RemovedReference);
749        }
750    }
751    if matches!(expected_head, ExpectedAcceptedHead::Empty)
752        && (references
753            .types
754            .iter()
755            .any(|reference| !types.contains_key(reference))
756            || references
757                .relation_entities
758                .iter()
759                .any(|reference| !entities.contains_key(reference)))
760    {
761        return Err(SchemaContractError::InvalidLocalReference);
762    }
763    Ok(())
764}
765
766fn collect_entity_references(
767    entity: &EntityFragment,
768    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
769    references: &mut ProposalReferences,
770) -> Result<(), SchemaContractError> {
771    for field in entity.fields() {
772        collect_field_references(field, types, references)?;
773    }
774    for relation in entity.relations() {
775        references
776            .relation_entities
777            .insert(relation.target_entity().clone());
778        references.relation_fields.extend(
779            relation
780                .target_fields()
781                .iter()
782                .cloned()
783                .map(|field| (relation.target_entity().clone(), field)),
784        );
785        if let RelationSourceFragment::Nested { steps, .. } = relation.source() {
786            references
787                .types
788                .extend(steps.iter().filter_map(|step| match step {
789                    RelationPathStepFragment::EnterNamed { r#type } => Some(r#type.clone()),
790                    RelationPathStepFragment::RecordMember { record, .. } => Some(record.clone()),
791                    RelationPathStepFragment::EnumVariantPayload { r#enum, .. } => {
792                        Some(r#enum.clone())
793                    }
794                    RelationPathStepFragment::OptionalSome
795                    | RelationPathStepFragment::ListItems
796                    | RelationPathStepFragment::SetItems
797                    | RelationPathStepFragment::MapValues => None,
798                }));
799        }
800    }
801    for index in entity.indexes() {
802        if let Some(predicate) = index.predicate() {
803            collect_expression_enum_references(predicate, types, references)?;
804        }
805    }
806    for constraint in entity.constraints() {
807        match constraint.kind() {
808            ConstraintFragmentKind::Check(expression) => {
809                collect_expression_enum_references(expression, types, references)?;
810            }
811            ConstraintFragmentKind::TargetedRule(rule) => {
812                references.types.insert(rule.target_type().clone());
813                validate_targeted_rule(entity, rule, types)?;
814            }
815        }
816    }
817    Ok(())
818}
819
820fn validate_targeted_rule(
821    entity: &EntityFragment,
822    rule: &TargetedRuleFragment,
823    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
824) -> Result<(), SchemaContractError> {
825    let root = entity
826        .fields()
827        .iter()
828        .find(|field| field.source_key() == rule.root())
829        .ok_or(SchemaContractError::InvalidLocalReference)?;
830    if types.contains_key(rule.target_type())
831        && !field_type_reaches_target(root.field_type(), rule.target_type(), types)
832    {
833        return Err(SchemaContractError::InvalidRuleTarget);
834    }
835    let Some(target) = types.get(rule.target_type()) else {
836        return Ok(());
837    };
838    let shape = resolve_rule_target_shape(target, types)?;
839    if operation_matches_target(rule.operation(), shape) {
840        Ok(())
841    } else {
842        Err(SchemaContractError::InvalidRuleTarget)
843    }
844}
845
846fn field_type_reaches_target(
847    root: &FieldType,
848    target: &TypeSourceKey,
849    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
850) -> bool {
851    let mut pending = vec![root];
852    let mut visited = BTreeSet::new();
853    while let Some(field_type) = pending.pop() {
854        match field_type {
855            FieldType::Scalar(_) => {}
856            FieldType::List(item) => pending.push(item),
857            FieldType::Named(source) => {
858                if source == target {
859                    return true;
860                }
861                if !visited.insert(source) {
862                    continue;
863                }
864                if let Some(definition) = types.get(source) {
865                    push_named_type_field_types(definition, &mut pending);
866                }
867            }
868        }
869    }
870    false
871}
872
873fn push_named_type_field_types<'types>(
874    r#type: &'types NamedTypeFragment,
875    pending: &mut Vec<&'types FieldType>,
876) {
877    match r#type {
878        NamedTypeFragment::Record(record) => {
879            pending.extend(
880                record
881                    .fields()
882                    .iter()
883                    .map(crate::RecordFieldFragment::field_type),
884            );
885        }
886        NamedTypeFragment::Enum(r#enum) => {
887            pending.extend(
888                r#enum
889                    .variants()
890                    .iter()
891                    .filter_map(|variant| variant.payload()),
892            );
893        }
894        NamedTypeFragment::Newtype { inner, .. }
895        | NamedTypeFragment::List { item: inner, .. }
896        | NamedTypeFragment::Set { item: inner, .. } => pending.push(inner),
897        NamedTypeFragment::Map { key, value, .. } => {
898            pending.push(key);
899            pending.push(value);
900        }
901        NamedTypeFragment::Tuple { members, .. } => {
902            pending.extend(members.iter().map(crate::TupleElementFragment::field_type));
903        }
904    }
905}
906
907#[derive(Clone, Copy)]
908enum RuleTargetShape {
909    Collection,
910    Scalar(ScalarType),
911}
912
913fn resolve_rule_target_shape(
914    target: &NamedTypeFragment,
915    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
916) -> Result<RuleTargetShape, SchemaContractError> {
917    let mut current = target;
918    let mut visited = BTreeSet::new();
919    loop {
920        if !visited.insert(current.source_key()) {
921            return Err(SchemaContractError::InvalidRuleTarget);
922        }
923        match current {
924            NamedTypeFragment::List { .. }
925            | NamedTypeFragment::Set { .. }
926            | NamedTypeFragment::Map { .. } => return Ok(RuleTargetShape::Collection),
927            NamedTypeFragment::Newtype { inner, .. } => match inner {
928                FieldType::Scalar(scalar) => return Ok(RuleTargetShape::Scalar(*scalar)),
929                FieldType::List(_) => return Ok(RuleTargetShape::Collection),
930                FieldType::Named(source) => {
931                    current = types
932                        .get(source)
933                        .copied()
934                        .ok_or(SchemaContractError::InvalidRuleTarget)?;
935                }
936            },
937            NamedTypeFragment::Record(_)
938            | NamedTypeFragment::Enum(_)
939            | NamedTypeFragment::Tuple { .. } => {
940                return Err(SchemaContractError::InvalidRuleTarget);
941            }
942        }
943    }
944}
945
946fn operation_matches_target(operation: &SourceRuleOperation, shape: RuleTargetShape) -> bool {
947    match (operation, shape) {
948        (
949            SourceRuleOperation::LengthRangeInclusive { .. },
950            RuleTargetShape::Collection
951            | RuleTargetShape::Scalar(ScalarType::Blob { .. } | ScalarType::Text { .. }),
952        ) => true,
953        (
954            SourceRuleOperation::NumericMaximumInclusive { value }
955            | SourceRuleOperation::NumericMinimumInclusive { value },
956            RuleTargetShape::Scalar(scalar),
957        ) => numeric_scalar(scalar) && numeric_rule_literal_matches(scalar, value),
958        (
959            SourceRuleOperation::NumericRangeInclusive { min, max },
960            RuleTargetShape::Scalar(scalar),
961        ) => {
962            numeric_scalar(scalar)
963                && numeric_rule_literal_matches(scalar, min)
964                && numeric_rule_literal_matches(scalar, max)
965        }
966        (SourceRuleOperation::MultipleOf { divisor }, RuleTargetShape::Scalar(scalar)) => {
967            exact_numeric_scalar(scalar) && numeric_rule_literal_matches(scalar, divisor)
968        }
969        _ => false,
970    }
971}
972
973fn numeric_rule_literal_matches(scalar: ScalarType, literal: &crate::ScalarLiteral) -> bool {
974    if let (ScalarType::Decimal { scale }, crate::ScalarLiteral::Decimal(value)) = (scalar, literal)
975    {
976        let value = value.normalize();
977        return value.scale() <= scale && value.scale_to_integer(scale).is_some();
978    }
979    scalar.accepts_literal(literal)
980}
981
982const fn exact_numeric_scalar(scalar: ScalarType) -> bool {
983    matches!(
984        scalar,
985        ScalarType::Decimal { .. }
986            | ScalarType::Int8
987            | ScalarType::Int16
988            | ScalarType::Int32
989            | ScalarType::Int64
990            | ScalarType::Int128
991            | ScalarType::Nat8
992            | ScalarType::Nat16
993            | ScalarType::Nat32
994            | ScalarType::Nat64
995            | ScalarType::Nat128
996    )
997}
998
999const fn numeric_scalar(scalar: ScalarType) -> bool {
1000    matches!(
1001        scalar,
1002        ScalarType::Decimal { .. }
1003            | ScalarType::Float32
1004            | ScalarType::Float64
1005            | ScalarType::Int8
1006            | ScalarType::Int16
1007            | ScalarType::Int32
1008            | ScalarType::Int64
1009            | ScalarType::Int128
1010            | ScalarType::Nat8
1011            | ScalarType::Nat16
1012            | ScalarType::Nat32
1013            | ScalarType::Nat64
1014            | ScalarType::Nat128
1015            | ScalarType::U256
1016    )
1017}
1018
1019fn collect_named_type_references(r#type: &NamedTypeFragment, references: &mut ProposalReferences) {
1020    match r#type {
1021        NamedTypeFragment::Record(record) => {
1022            for field in record.fields() {
1023                collect_field_type_reference(field.field_type(), references);
1024            }
1025        }
1026        NamedTypeFragment::Enum(r#enum) => {
1027            for variant in r#enum.variants() {
1028                if let Some(payload) = variant.payload() {
1029                    collect_field_type_reference(payload, references);
1030                }
1031            }
1032        }
1033        NamedTypeFragment::Newtype { inner, .. }
1034        | NamedTypeFragment::List { item: inner, .. }
1035        | NamedTypeFragment::Set { item: inner, .. } => {
1036            collect_field_type_reference(inner, references);
1037        }
1038        NamedTypeFragment::Map { key, value, .. } => {
1039            collect_field_type_reference(key, references);
1040            collect_field_type_reference(value, references);
1041        }
1042        NamedTypeFragment::Tuple { members, .. } => {
1043            for member in members {
1044                collect_field_type_reference(member.field_type(), references);
1045            }
1046        }
1047    }
1048}
1049
1050#[cfg(test)]
1051mod u256_tests {
1052    use super::*;
1053    use crate::{ScalarLiteral, U256};
1054
1055    #[test]
1056    fn u256_targeted_ranges_admit_while_multiple_of_remains_deferred() {
1057        let shape = RuleTargetShape::Scalar(ScalarType::U256);
1058        assert!(operation_matches_target(
1059            &SourceRuleOperation::NumericRangeInclusive {
1060                min: ScalarLiteral::U256(U256::ZERO),
1061                max: ScalarLiteral::U256(U256::MAX),
1062            },
1063            shape,
1064        ));
1065        assert!(!operation_matches_target(
1066            &SourceRuleOperation::MultipleOf {
1067                divisor: ScalarLiteral::U256(U256::ONE),
1068            },
1069            shape,
1070        ));
1071    }
1072}
1073
1074fn collect_field_references(
1075    field: &FieldFragment,
1076    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
1077    references: &mut ProposalReferences,
1078) -> Result<(), SchemaContractError> {
1079    collect_field_type_reference(field.field_type(), references);
1080    if let crate::FieldInsertPolicy::Default(ScalarLiteral::EnumUnit { enum_type, variant }) =
1081        field.insert_policy()
1082    {
1083        let FieldType::Named(field_type) = field.field_type() else {
1084            return Err(SchemaContractError::LiteralTypeMismatch);
1085        };
1086        if field_type != enum_type {
1087            return Err(SchemaContractError::LiteralTypeMismatch);
1088        }
1089        collect_enum_literal_reference(enum_type, variant, types, references)?;
1090    }
1091    Ok(())
1092}
1093
1094fn collect_field_type_reference(field_type: &FieldType, references: &mut ProposalReferences) {
1095    match field_type {
1096        FieldType::List(item) => collect_field_type_reference(item, references),
1097        FieldType::Named(reference) => {
1098            references.types.insert(reference.clone());
1099        }
1100        FieldType::Scalar(_) => {}
1101    }
1102}
1103
1104fn collect_expression_enum_references(
1105    expression: &SourceCheckExpr,
1106    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
1107    references: &mut ProposalReferences,
1108) -> Result<(), SchemaContractError> {
1109    for instruction in expression.instructions() {
1110        if let SourceCheckInstruction::Literal(ScalarLiteral::EnumUnit { enum_type, variant }) =
1111            instruction
1112        {
1113            collect_enum_literal_reference(enum_type, variant, types, references)?;
1114        }
1115    }
1116    Ok(())
1117}
1118
1119fn collect_enum_literal_reference(
1120    enum_type: &TypeSourceKey,
1121    variant: &TypeSourceKey,
1122    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
1123    references: &mut ProposalReferences,
1124) -> Result<(), SchemaContractError> {
1125    references.types.insert(enum_type.clone());
1126    let Some(local) = types.get(enum_type) else {
1127        return Ok(());
1128    };
1129    let NamedTypeFragment::Enum(local) = local else {
1130        return Err(SchemaContractError::InvalidEnumLiteral);
1131    };
1132    if local
1133        .variants()
1134        .iter()
1135        .all(|candidate| candidate.source_key() != variant)
1136    {
1137        return Err(SchemaContractError::InvalidEnumLiteral);
1138    }
1139    Ok(())
1140}
1141
1142fn validate_local_relation_targets(
1143    source: &EntityFragment,
1144    entities: &BTreeMap<EntitySourceKey, &EntityFragment>,
1145    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
1146) -> Result<(), SchemaContractError> {
1147    for relation in source.relations() {
1148        let Some(target) = entities.get(relation.target_entity()) else {
1149            continue;
1150        };
1151        match relation.source() {
1152            RelationSourceFragment::Direct { fields } => {
1153                for (source_key, target_key) in fields.iter().zip(relation.target_fields()) {
1154                    let source_field = source
1155                        .fields()
1156                        .iter()
1157                        .find(|field| field.source_key() == source_key)
1158                        .ok_or(SchemaContractError::InvalidLocalReference)?;
1159                    let target_field = target
1160                        .fields()
1161                        .iter()
1162                        .find(|field| field.source_key() == target_key)
1163                        .ok_or(SchemaContractError::InvalidLocalReference)?;
1164                    let source_type = match source_field.field_type() {
1165                        FieldType::List(item) => item.as_ref(),
1166                        field_type => field_type,
1167                    };
1168                    if source_type != target_field.field_type() {
1169                        return Err(SchemaContractError::RelationTypeMismatch);
1170                    }
1171                }
1172            }
1173            RelationSourceFragment::Nested { .. } => {
1174                let terminal = validate_nested_relation_path(source, relation, types)?;
1175                let target_key = relation
1176                    .target_fields()
1177                    .first()
1178                    .ok_or(SchemaContractError::InvalidReferenceList)?;
1179                let target_field = target
1180                    .fields()
1181                    .iter()
1182                    .find(|field| field.source_key() == target_key)
1183                    .ok_or(SchemaContractError::InvalidLocalReference)?;
1184                if terminal != target_field.field_type() {
1185                    return Err(SchemaContractError::RelationTypeMismatch);
1186                }
1187            }
1188        }
1189    }
1190    Ok(())
1191}
1192
1193fn validate_nested_relation_path<'schema>(
1194    source: &'schema EntityFragment,
1195    relation: &RelationFragment,
1196    types: &BTreeMap<TypeSourceKey, &'schema NamedTypeFragment>,
1197) -> Result<&'schema FieldType, SchemaContractError> {
1198    let RelationSourceFragment::Nested { root, steps } = relation.source() else {
1199        return Err(SchemaContractError::RelationTypeMismatch);
1200    };
1201    let root = source
1202        .fields()
1203        .iter()
1204        .find(|field| field.source_key() == root)
1205        .ok_or(SchemaContractError::InvalidLocalReference)?;
1206    let mut kind = root.field_type();
1207    let mut nullable = root.nullable();
1208    let mut entered = None;
1209    for step in steps {
1210        match step {
1211            RelationPathStepFragment::OptionalSome => {
1212                if !nullable || entered.is_some() {
1213                    return Err(SchemaContractError::RelationTypeMismatch);
1214                }
1215                nullable = false;
1216            }
1217            RelationPathStepFragment::EnterNamed { r#type } => {
1218                let FieldType::Named(named) = kind else {
1219                    return Err(SchemaContractError::RelationTypeMismatch);
1220                };
1221                if nullable || entered.is_some() || named != r#type {
1222                    return Err(SchemaContractError::RelationTypeMismatch);
1223                }
1224                match types.get(named).copied() {
1225                    Some(
1226                        NamedTypeFragment::Record(_)
1227                        | NamedTypeFragment::Enum(_)
1228                        | NamedTypeFragment::List { .. }
1229                        | NamedTypeFragment::Set { .. }
1230                        | NamedTypeFragment::Map { .. },
1231                    ) => {
1232                        entered = Some(named);
1233                    }
1234                    Some(NamedTypeFragment::Newtype { inner, .. }) => {
1235                        kind = inner;
1236                    }
1237                    Some(NamedTypeFragment::Tuple { .. }) | None => {
1238                        return Err(SchemaContractError::RelationTypeMismatch);
1239                    }
1240                }
1241            }
1242            RelationPathStepFragment::RecordMember { record, field } => {
1243                if nullable || entered != Some(record) {
1244                    return Err(SchemaContractError::RelationTypeMismatch);
1245                }
1246                let Some(NamedTypeFragment::Record(definition)) = types.get(record).copied() else {
1247                    return Err(SchemaContractError::RelationTypeMismatch);
1248                };
1249                let member = definition
1250                    .fields()
1251                    .iter()
1252                    .find(|member| member.source_key() == field)
1253                    .ok_or(SchemaContractError::InvalidLocalReference)?;
1254                kind = member.field_type();
1255                nullable = member.nullable();
1256                entered = None;
1257            }
1258            RelationPathStepFragment::EnumVariantPayload { r#enum, variant } => {
1259                if nullable || entered != Some(r#enum) {
1260                    return Err(SchemaContractError::RelationTypeMismatch);
1261                }
1262                let Some(NamedTypeFragment::Enum(definition)) = types.get(r#enum).copied() else {
1263                    return Err(SchemaContractError::RelationTypeMismatch);
1264                };
1265                let payload = definition
1266                    .variants()
1267                    .iter()
1268                    .find(|candidate| candidate.source_key() == variant)
1269                    .and_then(EnumVariantFragment::payload)
1270                    .ok_or(SchemaContractError::InvalidLocalReference)?;
1271                kind = payload;
1272                nullable = false;
1273                entered = None;
1274            }
1275            RelationPathStepFragment::ListItems
1276            | RelationPathStepFragment::SetItems
1277            | RelationPathStepFragment::MapValues => {
1278                if nullable {
1279                    return Err(SchemaContractError::RelationTypeMismatch);
1280                }
1281                kind = relation_collection_step_output(kind, entered.take(), step, types)?;
1282                nullable = false;
1283            }
1284        }
1285    }
1286    if nullable || entered.is_some() || !matches!(kind, FieldType::Scalar(_)) {
1287        return Err(SchemaContractError::RelationTypeMismatch);
1288    }
1289    Ok(kind)
1290}
1291
1292fn relation_collection_step_output<'schema>(
1293    kind: &'schema FieldType,
1294    entered: Option<&TypeSourceKey>,
1295    step: &RelationPathStepFragment,
1296    types: &BTreeMap<TypeSourceKey, &'schema NamedTypeFragment>,
1297) -> Result<&'schema FieldType, SchemaContractError> {
1298    match (step, entered, kind) {
1299        (RelationPathStepFragment::ListItems, None, FieldType::List(item)) => Ok(item),
1300        (RelationPathStepFragment::ListItems, Some(named), FieldType::Named(current))
1301            if named == current =>
1302        {
1303            let Some(NamedTypeFragment::List { item, .. }) = types.get(current).copied() else {
1304                return Err(SchemaContractError::RelationTypeMismatch);
1305            };
1306            Ok(item)
1307        }
1308        (RelationPathStepFragment::SetItems, Some(named), FieldType::Named(current))
1309            if named == current =>
1310        {
1311            let Some(NamedTypeFragment::Set { item, .. }) = types.get(current).copied() else {
1312                return Err(SchemaContractError::RelationTypeMismatch);
1313            };
1314            Ok(item)
1315        }
1316        (RelationPathStepFragment::MapValues, Some(named), FieldType::Named(current))
1317            if named == current =>
1318        {
1319            let Some(NamedTypeFragment::Map { value, .. }) = types.get(current).copied() else {
1320                return Err(SchemaContractError::RelationTypeMismatch);
1321            };
1322            Ok(value)
1323        }
1324        _ => Err(SchemaContractError::RelationTypeMismatch),
1325    }
1326}
1327
1328fn ensure_no_adjacent_duplicates<T>(values: &[T]) -> Result<(), SchemaContractError>
1329where
1330    T: Eq,
1331{
1332    if values.windows(2).any(|pair| pair[0] == pair[1]) {
1333        return Err(SchemaContractError::DuplicateSourceKey);
1334    }
1335    Ok(())
1336}
1337
1338fn ensure_no_adjacent_duplicates_by<T, K>(
1339    values: &[T],
1340    key: impl Fn(&T) -> &K,
1341) -> Result<(), SchemaContractError>
1342where
1343    K: Eq,
1344{
1345    if values.windows(2).any(|pair| key(&pair[0]) == key(&pair[1])) {
1346        return Err(SchemaContractError::DuplicateSourceKey);
1347    }
1348    Ok(())
1349}
1350
1351#[cfg(test)]
1352mod tests {
1353    use super::*;
1354    use crate::decode_schema_proposal;
1355    use std::str::FromStr;
1356
1357    fn empty_proposal_with(capabilities: Vec<SchemaCapability>) -> SchemaProposal {
1358        SchemaProposal::try_compose(
1359            capabilities,
1360            TargetDatabaseIdentity::from_bytes([1; 32]),
1361            SchemaSubmissionKey::try_new("proposal-version-test")
1362                .expect("submission key should admit"),
1363            ExpectedAcceptedHead::Empty,
1364            Vec::new(),
1365            Vec::new(),
1366            Vec::new(),
1367            None,
1368        )
1369        .expect("empty proposal should compose")
1370    }
1371
1372    #[test]
1373    fn decoded_future_contract_version_fails_typed() {
1374        let proposal = empty_proposal_with(Vec::new());
1375        let mut bytes = crate::encode_schema_proposal(&proposal).expect("proposal should encode");
1376        bytes[5..7].copy_from_slice(&2_u16.to_be_bytes());
1377
1378        assert_eq!(
1379            decode_schema_proposal(&bytes),
1380            Err(SchemaContractError::UnsupportedVersion {
1381                found: 2,
1382                supported: 1,
1383            }),
1384        );
1385    }
1386
1387    #[test]
1388    fn decoded_unknown_capability_fails_typed() {
1389        let proposal = empty_proposal_with(vec![SchemaCapability::EXACT_COMPOSITE_TYPES]);
1390        let mut bytes = crate::encode_schema_proposal(&proposal).expect("proposal should encode");
1391        bytes[11..13].copy_from_slice(&u16::MAX.to_be_bytes());
1392
1393        assert_eq!(
1394            decode_schema_proposal(&bytes),
1395            Err(SchemaContractError::UnsupportedCapability),
1396        );
1397    }
1398
1399    #[test]
1400    fn targeted_numeric_operations_require_exact_target_literal_admission() {
1401        let decimal = |value| {
1402            crate::ScalarLiteral::Decimal(
1403                crate::Decimal::from_str(value).expect("decimal fixture should parse"),
1404            )
1405        };
1406        assert!(operation_matches_target(
1407            &SourceRuleOperation::MultipleOf {
1408                divisor: decimal("0.25"),
1409            },
1410            RuleTargetShape::Scalar(ScalarType::Decimal { scale: 2 }),
1411        ));
1412        assert!(!operation_matches_target(
1413            &SourceRuleOperation::MultipleOf {
1414                divisor: decimal("0.251"),
1415            },
1416            RuleTargetShape::Scalar(ScalarType::Decimal { scale: 2 }),
1417        ));
1418        assert!(!operation_matches_target(
1419            &SourceRuleOperation::MultipleOf {
1420                divisor: crate::ScalarLiteral::Float64(
1421                    crate::Float64::try_new(2.0).expect("finite float"),
1422                ),
1423            },
1424            RuleTargetShape::Scalar(ScalarType::Float64),
1425        ));
1426        assert!(operation_matches_target(
1427            &SourceRuleOperation::NumericMaximumInclusive {
1428                value: crate::ScalarLiteral::Float64(
1429                    crate::Float64::try_new(2.0).expect("finite float"),
1430                ),
1431            },
1432            RuleTargetShape::Scalar(ScalarType::Float64),
1433        ));
1434    }
1435}