Skip to main content

icydb_schema/
proposal.rs

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