Skip to main content

type_bridge_schema/
delta_safety.rs

1//! Provider-neutral migration safety classification.
2
3use std::collections::BTreeSet;
4
5use serde::Serialize;
6use type_bridge_contract::id::FunctionId;
7use type_bridge_contract::schema::{
8    AnnotationFact, AnnotationKindId, AnnotationSubjectId, SchemaAnnotationValue, SchemaDelta,
9    SchemaFact, SchemaFactId, SchemaOperation, SchemaOperationKind,
10};
11
12/// The exact eight-class provider-neutral migration safety vocabulary.
13#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
14#[serde(rename_all = "snake_case")]
15pub enum SafetyClass {
16    /// Changes only the formal fact representation, without changing schema behavior.
17    FormalOnly,
18    /// Changes documentation or other schema metadata only.
19    SchemaMetadata,
20    /// Adds schema behavior without invalidating existing data.
21    Additive,
22    /// Is safe only when separately derived conditions hold.
23    Conditional,
24    /// Requires existing data to be populated before the schema change can complete.
25    BackfillRequired,
26    /// Can remove or invalidate existing schema or data.
27    Destructive,
28    /// Cannot be classified because its provider semantics are opaque.
29    Opaque,
30    /// Has no supported lowering or execution path.
31    Unsupported,
32}
33
34impl SafetyClass {
35    /// Every safety class in stable least-to-most restrictive order.
36    pub const ALL: [Self; 8] = [
37        Self::FormalOnly,
38        Self::SchemaMetadata,
39        Self::Additive,
40        Self::Conditional,
41        Self::BackfillRequired,
42        Self::Destructive,
43        Self::Opaque,
44        Self::Unsupported,
45    ];
46}
47
48/// Compatibility name retained for the schema-delta API.
49pub type DeltaSafety = SafetyClass;
50
51/// A malformed formal transition that cannot be assigned a safety class.
52#[derive(Clone, Copy, Debug, Eq, PartialEq)]
53pub enum SafetyClassificationError {
54    /// A relates replacement changed neither specialization nor collection mode.
55    UnchangedRelatesSpecialization,
56    /// A redefinition changed the fact category under one identity.
57    RedefinitionCategoryChanged,
58}
59
60impl SafetyClassificationError {
61    /// Return the stable diagnostic message used by lowering boundaries.
62    pub const fn message(self) -> &'static str {
63        match self {
64            Self::UnchangedRelatesSpecialization => {
65                "relates redefinition does not change specialization or collection mode"
66            }
67            Self::RedefinitionCategoryChanged => "schema redefinition changed fact category",
68        }
69    }
70}
71
72/// Classify one validated formal operation without granting execution authority.
73pub fn classify_operation_safety(
74    operation: &SchemaOperation,
75) -> Result<SafetyClass, SafetyClassificationError> {
76    let mut safety = SafetyClass::FormalOnly;
77    match operation.kind() {
78        SchemaOperationKind::Define => {
79            let facts = operation.defined_facts().expect("define exposes facts");
80            let functions = facts
81                .iter()
82                .filter_map(|fact| match fact {
83                    SchemaFact::Function(function) => Some(function.id().clone()),
84                    _ => None,
85                })
86                .collect::<BTreeSet<FunctionId>>();
87            for fact in facts {
88                safety = safety.max(classify_defined_fact(fact, &functions));
89            }
90        }
91        SchemaOperationKind::Redefine => {
92            safety = classify_redefinition(
93                operation
94                    .expected_fact()
95                    .expect("redefine exposes expected fact"),
96                operation
97                    .replacement_fact()
98                    .expect("redefine exposes replacement fact"),
99            )?;
100        }
101        SchemaOperationKind::Undefine => {
102            safety =
103                classify_undefined_fact(operation.undefined_fact().expect("undefine exposes fact"));
104        }
105    }
106    Ok(safety)
107}
108
109fn classify_defined_fact(fact: &SchemaFact, functions: &BTreeSet<FunctionId>) -> SafetyClass {
110    match fact {
111        SchemaFact::Relates(relates) if relates.specializes().is_some() => SafetyClass::Conditional,
112        SchemaFact::Annotation(annotation) => {
113            if let AnnotationSubjectId::Function(function) = annotation.id().subject()
114                && functions.contains(function)
115                && matches!(
116                    annotation.id().kind(),
117                    AnnotationKindId::Doc | AnnotationKindId::Meta(_)
118                )
119            {
120                SafetyClass::SchemaMetadata
121            } else {
122                classify_annotation(annotation, AnnotationTransition::Add, None)
123            }
124        }
125        _ => classify_fact(fact, FactTransition::Define),
126    }
127}
128
129fn classify_undefined_fact(fact: &SchemaFact) -> SafetyClass {
130    match fact {
131        SchemaFact::Annotation(annotation) => {
132            classify_annotation(annotation, AnnotationTransition::Remove, None)
133        }
134        SchemaFact::Relates(relates) if relates.specializes().is_some() => SafetyClass::Conditional,
135        _ => classify_fact(fact, FactTransition::Undefine),
136    }
137}
138
139fn classify_redefinition(
140    expected: &SchemaFact,
141    replacement: &SchemaFact,
142) -> Result<SafetyClass, SafetyClassificationError> {
143    match (expected, replacement) {
144        (SchemaFact::Relates(old), SchemaFact::Relates(new)) => {
145            if old.collection_mode() != new.collection_mode() {
146                Ok(SafetyClass::Unsupported)
147            } else if old.specializes() != new.specializes() {
148                Ok(SafetyClass::Conditional)
149            } else {
150                Err(SafetyClassificationError::UnchangedRelatesSpecialization)
151            }
152        }
153        (SchemaFact::Annotation(old), SchemaFact::Annotation(new)) => Ok(classify_annotation(
154            new,
155            AnnotationTransition::Change,
156            Some(old),
157        )),
158        (left, right) if std::mem::discriminant(left) == std::mem::discriminant(right) => {
159            Ok(classify_fact(right, FactTransition::Redefine))
160        }
161        _ => Err(SafetyClassificationError::RedefinitionCategoryChanged),
162    }
163}
164
165#[derive(Clone, Copy)]
166enum FactTransition {
167    Define,
168    Undefine,
169    Redefine,
170}
171
172fn classify_fact(fact: &SchemaFact, transition: FactTransition) -> SafetyClass {
173    use FactTransition::{Define, Redefine, Undefine};
174    use SafetyClass::{Additive, Conditional, Destructive, Opaque, Unsupported};
175
176    match (fact, transition) {
177        (SchemaFact::Type(_), Define) => Additive,
178        (SchemaFact::Type(_), Undefine) => Destructive,
179        (SchemaFact::Type(_), Redefine) => Unsupported,
180        (SchemaFact::Sub(_), Define | Redefine) => Conditional,
181        (SchemaFact::Sub(_), Undefine) => Destructive,
182        (SchemaFact::Value(_), Define) => Additive,
183        (SchemaFact::Value(_), Undefine | Redefine) => Destructive,
184        (SchemaFact::Owns(_) | SchemaFact::Relates(_) | SchemaFact::Plays(_), Define) => Additive,
185        (SchemaFact::Owns(_) | SchemaFact::Relates(_) | SchemaFact::Plays(_), Undefine) => {
186            Destructive
187        }
188        (SchemaFact::Owns(_) | SchemaFact::Relates(_) | SchemaFact::Plays(_), Redefine) => {
189            Unsupported
190        }
191        (SchemaFact::Function(_), Define) => Additive,
192        (SchemaFact::Function(_), Undefine) => Destructive,
193        (SchemaFact::Function(_), Redefine) => Opaque,
194        (SchemaFact::Struct(_), _) => Unsupported,
195        (SchemaFact::Annotation(_), _) => {
196            unreachable!("annotations use the annotation classifier")
197        }
198    }
199}
200
201#[derive(Clone, Copy)]
202enum AnnotationTransition {
203    Add,
204    Change,
205    Remove,
206}
207
208fn classify_annotation(
209    annotation: &AnnotationFact,
210    transition: AnnotationTransition,
211    expected: Option<&AnnotationFact>,
212) -> SafetyClass {
213    let subject = annotation.id().subject();
214    let kind = annotation.id().kind();
215    if !annotation_supported(subject, kind) {
216        return SafetyClass::Unsupported;
217    }
218    if matches!(subject, AnnotationSubjectId::Sub(_))
219        || matches!(kind, AnnotationKindId::Doc | AnnotationKindId::Meta(_))
220    {
221        return SafetyClass::SchemaMetadata;
222    }
223
224    let mut safety = match kind {
225        AnnotationKindId::Abstract => match transition {
226            AnnotationTransition::Add => SafetyClass::Conditional,
227            AnnotationTransition::Remove => SafetyClass::Additive,
228            AnnotationTransition::Change => SafetyClass::Unsupported,
229        },
230        AnnotationKindId::Independent => match transition {
231            AnnotationTransition::Add => SafetyClass::Additive,
232            AnnotationTransition::Remove => SafetyClass::Destructive,
233            AnnotationTransition::Change => SafetyClass::Unsupported,
234        },
235        AnnotationKindId::Key | AnnotationKindId::Unique => match transition {
236            AnnotationTransition::Add => SafetyClass::BackfillRequired,
237            AnnotationTransition::Remove => SafetyClass::Additive,
238            AnnotationTransition::Change => SafetyClass::Unsupported,
239        },
240        AnnotationKindId::Distinct => SafetyClass::Unsupported,
241        AnnotationKindId::Card => SafetyClass::Conditional,
242        AnnotationKindId::Regex | AnnotationKindId::Range | AnnotationKindId::Values => {
243            match transition {
244                AnnotationTransition::Add | AnnotationTransition::Change => {
245                    SafetyClass::Conditional
246                }
247                AnnotationTransition::Remove => SafetyClass::Additive,
248            }
249        }
250        AnnotationKindId::Doc | AnnotationKindId::Meta(_) => {
251            unreachable!("metadata annotations returned above")
252        }
253    };
254
255    if matches!(kind, AnnotationKindId::Card)
256        && let Some(target) = annotation_cardinality(annotation)
257        && let Some(default) = default_cardinality(subject)
258    {
259        let (from, to) = match transition {
260            AnnotationTransition::Add => (default, target),
261            AnnotationTransition::Change => {
262                let Some(source) = expected.and_then(annotation_cardinality) else {
263                    return safety;
264                };
265                (source, target)
266            }
267            AnnotationTransition::Remove => (target, default),
268        };
269        safety = cardinality_transition_safety(from, to);
270    }
271    safety
272}
273
274fn annotation_supported(subject: &AnnotationSubjectId, kind: &AnnotationKindId) -> bool {
275    match subject {
276        AnnotationSubjectId::Type(_) => matches!(
277            kind,
278            AnnotationKindId::Abstract
279                | AnnotationKindId::Independent
280                | AnnotationKindId::Doc
281                | AnnotationKindId::Meta(_)
282        ),
283        AnnotationSubjectId::Sub(_) => {
284            matches!(kind, AnnotationKindId::Doc | AnnotationKindId::Meta(_))
285        }
286        AnnotationSubjectId::Value(_) => matches!(
287            kind,
288            AnnotationKindId::Regex
289                | AnnotationKindId::Range
290                | AnnotationKindId::Values
291                | AnnotationKindId::Doc
292                | AnnotationKindId::Meta(_)
293        ),
294        AnnotationSubjectId::Owns(_) => matches!(
295            kind,
296            AnnotationKindId::Key
297                | AnnotationKindId::Unique
298                | AnnotationKindId::Distinct
299                | AnnotationKindId::Card
300                | AnnotationKindId::Regex
301                | AnnotationKindId::Range
302                | AnnotationKindId::Values
303                | AnnotationKindId::Doc
304                | AnnotationKindId::Meta(_)
305        ),
306        AnnotationSubjectId::Relates(_) => matches!(
307            kind,
308            AnnotationKindId::Abstract
309                | AnnotationKindId::Distinct
310                | AnnotationKindId::Card
311                | AnnotationKindId::Doc
312                | AnnotationKindId::Meta(_)
313        ),
314        AnnotationSubjectId::Plays(_) => matches!(
315            kind,
316            AnnotationKindId::Card | AnnotationKindId::Doc | AnnotationKindId::Meta(_)
317        ),
318        AnnotationSubjectId::Function(_) => false,
319    }
320}
321
322fn annotation_cardinality(annotation: &AnnotationFact) -> Option<(u64, Option<u64>)> {
323    match annotation.value() {
324        SchemaAnnotationValue::Cardinality(cardinality) => {
325            Some(((*cardinality).min(), (*cardinality).max()))
326        }
327        _ => None,
328    }
329}
330
331fn default_cardinality(subject: &AnnotationSubjectId) -> Option<(u64, Option<u64>)> {
332    match subject {
333        AnnotationSubjectId::Owns(_) | AnnotationSubjectId::Relates(_) => Some((0, Some(1))),
334        AnnotationSubjectId::Plays(_) => Some((0, None)),
335        _ => None,
336    }
337}
338
339fn cardinality_transition_safety(from: (u64, Option<u64>), to: (u64, Option<u64>)) -> SafetyClass {
340    if from == to {
341        SafetyClass::FormalOnly
342    } else if interval_contains(to, from) {
343        SafetyClass::Additive
344    } else if interval_contains(from, to) {
345        SafetyClass::BackfillRequired
346    } else {
347        SafetyClass::Conditional
348    }
349}
350
351fn interval_contains(outer: (u64, Option<u64>), inner: (u64, Option<u64>)) -> bool {
352    outer.0 <= inner.0
353        && match (outer.1, inner.1) {
354            (None, _) => true,
355            (Some(_), None) => false,
356            (Some(outer), Some(inner)) => outer >= inner,
357        }
358}
359
360/// One deterministic fact-level reason for the aggregate classification.
361#[derive(Debug, Clone, PartialEq, Eq)]
362pub struct DeltaSafetyReason {
363    operation_index: usize,
364    fact_id: SchemaFactId,
365    classification: DeltaSafety,
366}
367
368impl DeltaSafetyReason {
369    /// Return the operation position in the canonical vector.
370    #[must_use]
371    pub const fn operation_index(&self) -> usize {
372        self.operation_index
373    }
374
375    /// Return the affected fact identity.
376    #[must_use]
377    pub const fn fact_id(&self) -> &SchemaFactId {
378        &self.fact_id
379    }
380
381    /// Return this fact's conservative safety classification.
382    #[must_use]
383    pub const fn classification(&self) -> DeltaSafety {
384        self.classification
385    }
386}
387
388/// Advisory classification only; it never grants authorization to execute.
389#[derive(Debug, Clone, PartialEq, Eq)]
390pub struct DeltaSafetyReport {
391    classification: DeltaSafety,
392    reasons: Vec<DeltaSafetyReason>,
393}
394
395impl DeltaSafetyReport {
396    /// Return the strongest condition in the delta.
397    #[must_use]
398    pub const fn classification(&self) -> DeltaSafety {
399        self.classification
400    }
401
402    /// Return deterministic fact-level reasons in operation order.
403    #[must_use]
404    pub fn reasons(&self) -> &[DeltaSafetyReason] {
405        &self.reasons
406    }
407}
408
409/// Classify one operation and fail malformed transitions closed as unsupported.
410#[must_use]
411pub fn classify_schema_operation_safety(operation: &SchemaOperation) -> DeltaSafety {
412    classify_operation_safety(operation).unwrap_or(DeltaSafety::Unsupported)
413}
414
415/// Classify a delta without consulting or producing an authorization decision.
416#[must_use]
417pub fn classify_delta_safety(delta: &SchemaDelta) -> DeltaSafetyReport {
418    let mut reasons = Vec::new();
419    for (operation_index, operation) in delta.operations().iter().enumerate() {
420        let classification = classify_schema_operation_safety(operation);
421        for fact_id in operation.affected_ids() {
422            reasons.push(DeltaSafetyReason {
423                operation_index,
424                fact_id,
425                classification,
426            });
427        }
428    }
429    let classification = reasons
430        .iter()
431        .map(DeltaSafetyReason::classification)
432        .max()
433        .unwrap_or(DeltaSafety::FormalOnly);
434    DeltaSafetyReport {
435        classification,
436        reasons,
437    }
438}