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