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 retained the absence of a specialization.
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"
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            match (old.specializes(), new.specializes()) {
146                (None, None) => Err(SafetyClassificationError::UnchangedRelatesSpecialization),
147                _ => Ok(SafetyClass::Conditional),
148            }
149        }
150        (SchemaFact::Annotation(old), SchemaFact::Annotation(new)) => Ok(classify_annotation(
151            new,
152            AnnotationTransition::Change,
153            Some(old),
154        )),
155        (left, right) if std::mem::discriminant(left) == std::mem::discriminant(right) => {
156            Ok(classify_fact(right, FactTransition::Redefine))
157        }
158        _ => Err(SafetyClassificationError::RedefinitionCategoryChanged),
159    }
160}
161
162#[derive(Clone, Copy)]
163enum FactTransition {
164    Define,
165    Undefine,
166    Redefine,
167}
168
169fn classify_fact(fact: &SchemaFact, transition: FactTransition) -> SafetyClass {
170    use FactTransition::{Define, Redefine, Undefine};
171    use SafetyClass::{Additive, Conditional, Destructive, Opaque, Unsupported};
172
173    match (fact, transition) {
174        (SchemaFact::Type(_), Define) => Additive,
175        (SchemaFact::Type(_), Undefine) => Destructive,
176        (SchemaFact::Type(_), Redefine) => Unsupported,
177        (SchemaFact::Sub(_), Define | Redefine) => Conditional,
178        (SchemaFact::Sub(_), Undefine) => Destructive,
179        (SchemaFact::Value(_), Define) => Additive,
180        (SchemaFact::Value(_), Undefine | Redefine) => Destructive,
181        (SchemaFact::Owns(_) | SchemaFact::Relates(_) | SchemaFact::Plays(_), Define) => Additive,
182        (SchemaFact::Owns(_) | SchemaFact::Relates(_) | SchemaFact::Plays(_), Undefine) => {
183            Destructive
184        }
185        (SchemaFact::Owns(_) | SchemaFact::Relates(_) | SchemaFact::Plays(_), Redefine) => {
186            Unsupported
187        }
188        (SchemaFact::Function(_), Define) => Additive,
189        (SchemaFact::Function(_), Undefine) => Destructive,
190        (SchemaFact::Function(_), Redefine) => Opaque,
191        (SchemaFact::Struct(_), _) => Unsupported,
192        (SchemaFact::Annotation(_), _) => {
193            unreachable!("annotations use the annotation classifier")
194        }
195    }
196}
197
198#[derive(Clone, Copy)]
199enum AnnotationTransition {
200    Add,
201    Change,
202    Remove,
203}
204
205fn classify_annotation(
206    annotation: &AnnotationFact,
207    transition: AnnotationTransition,
208    expected: Option<&AnnotationFact>,
209) -> SafetyClass {
210    let subject = annotation.id().subject();
211    let kind = annotation.id().kind();
212    if !annotation_supported(subject, kind) {
213        return SafetyClass::Unsupported;
214    }
215    if matches!(subject, AnnotationSubjectId::Sub(_))
216        || matches!(kind, AnnotationKindId::Doc | AnnotationKindId::Meta(_))
217    {
218        return SafetyClass::SchemaMetadata;
219    }
220
221    let mut safety = match kind {
222        AnnotationKindId::Abstract => match transition {
223            AnnotationTransition::Add => SafetyClass::Conditional,
224            AnnotationTransition::Remove => SafetyClass::Additive,
225            AnnotationTransition::Change => SafetyClass::Unsupported,
226        },
227        AnnotationKindId::Independent => match transition {
228            AnnotationTransition::Add => SafetyClass::Additive,
229            AnnotationTransition::Remove => SafetyClass::Destructive,
230            AnnotationTransition::Change => SafetyClass::Unsupported,
231        },
232        AnnotationKindId::Key | AnnotationKindId::Unique => match transition {
233            AnnotationTransition::Add => SafetyClass::BackfillRequired,
234            AnnotationTransition::Remove => SafetyClass::Additive,
235            AnnotationTransition::Change => SafetyClass::Unsupported,
236        },
237        AnnotationKindId::Card => SafetyClass::Conditional,
238        AnnotationKindId::Regex | AnnotationKindId::Range | AnnotationKindId::Values => {
239            match transition {
240                AnnotationTransition::Add | AnnotationTransition::Change => {
241                    SafetyClass::Conditional
242                }
243                AnnotationTransition::Remove => SafetyClass::Additive,
244            }
245        }
246        AnnotationKindId::Doc | AnnotationKindId::Meta(_) => {
247            unreachable!("metadata annotations returned above")
248        }
249    };
250
251    if matches!(kind, AnnotationKindId::Card)
252        && let Some(target) = annotation_cardinality(annotation)
253        && let Some(default) = default_cardinality(subject)
254    {
255        let (from, to) = match transition {
256            AnnotationTransition::Add => (default, target),
257            AnnotationTransition::Change => {
258                let Some(source) = expected.and_then(annotation_cardinality) else {
259                    return safety;
260                };
261                (source, target)
262            }
263            AnnotationTransition::Remove => (target, default),
264        };
265        safety = cardinality_transition_safety(from, to);
266    }
267    safety
268}
269
270fn annotation_supported(subject: &AnnotationSubjectId, kind: &AnnotationKindId) -> bool {
271    match subject {
272        AnnotationSubjectId::Type(_) => matches!(
273            kind,
274            AnnotationKindId::Abstract
275                | AnnotationKindId::Independent
276                | AnnotationKindId::Doc
277                | AnnotationKindId::Meta(_)
278        ),
279        AnnotationSubjectId::Sub(_) => {
280            matches!(kind, AnnotationKindId::Doc | AnnotationKindId::Meta(_))
281        }
282        AnnotationSubjectId::Value(_) => matches!(
283            kind,
284            AnnotationKindId::Regex
285                | AnnotationKindId::Range
286                | AnnotationKindId::Values
287                | AnnotationKindId::Doc
288                | AnnotationKindId::Meta(_)
289        ),
290        AnnotationSubjectId::Owns(_) => matches!(
291            kind,
292            AnnotationKindId::Key
293                | AnnotationKindId::Unique
294                | AnnotationKindId::Card
295                | AnnotationKindId::Regex
296                | AnnotationKindId::Range
297                | AnnotationKindId::Values
298                | AnnotationKindId::Doc
299                | AnnotationKindId::Meta(_)
300        ),
301        AnnotationSubjectId::Relates(_) => matches!(
302            kind,
303            AnnotationKindId::Abstract
304                | AnnotationKindId::Card
305                | AnnotationKindId::Doc
306                | AnnotationKindId::Meta(_)
307        ),
308        AnnotationSubjectId::Plays(_) => matches!(
309            kind,
310            AnnotationKindId::Card | AnnotationKindId::Doc | AnnotationKindId::Meta(_)
311        ),
312        AnnotationSubjectId::Function(_) => false,
313    }
314}
315
316fn annotation_cardinality(annotation: &AnnotationFact) -> Option<(u64, Option<u64>)> {
317    match annotation.value() {
318        SchemaAnnotationValue::Cardinality(cardinality) => {
319            Some(((*cardinality).min(), (*cardinality).max()))
320        }
321        _ => None,
322    }
323}
324
325fn default_cardinality(subject: &AnnotationSubjectId) -> Option<(u64, Option<u64>)> {
326    match subject {
327        AnnotationSubjectId::Owns(_) | AnnotationSubjectId::Relates(_) => Some((0, Some(1))),
328        AnnotationSubjectId::Plays(_) => Some((0, None)),
329        _ => None,
330    }
331}
332
333fn cardinality_transition_safety(from: (u64, Option<u64>), to: (u64, Option<u64>)) -> SafetyClass {
334    if from == to {
335        SafetyClass::FormalOnly
336    } else if interval_contains(to, from) {
337        SafetyClass::Additive
338    } else if interval_contains(from, to) {
339        SafetyClass::BackfillRequired
340    } else {
341        SafetyClass::Conditional
342    }
343}
344
345fn interval_contains(outer: (u64, Option<u64>), inner: (u64, Option<u64>)) -> bool {
346    outer.0 <= inner.0
347        && match (outer.1, inner.1) {
348            (None, _) => true,
349            (Some(_), None) => false,
350            (Some(outer), Some(inner)) => outer >= inner,
351        }
352}
353
354/// One deterministic fact-level reason for the aggregate classification.
355#[derive(Debug, Clone, PartialEq, Eq)]
356pub struct DeltaSafetyReason {
357    operation_index: usize,
358    fact_id: SchemaFactId,
359    classification: DeltaSafety,
360}
361
362impl DeltaSafetyReason {
363    /// Return the operation position in the canonical vector.
364    #[must_use]
365    pub const fn operation_index(&self) -> usize {
366        self.operation_index
367    }
368
369    /// Return the affected fact identity.
370    #[must_use]
371    pub const fn fact_id(&self) -> &SchemaFactId {
372        &self.fact_id
373    }
374
375    /// Return this fact's conservative safety classification.
376    #[must_use]
377    pub const fn classification(&self) -> DeltaSafety {
378        self.classification
379    }
380}
381
382/// Advisory classification only; it never grants authorization to execute.
383#[derive(Debug, Clone, PartialEq, Eq)]
384pub struct DeltaSafetyReport {
385    classification: DeltaSafety,
386    reasons: Vec<DeltaSafetyReason>,
387}
388
389impl DeltaSafetyReport {
390    /// Return the strongest condition in the delta.
391    #[must_use]
392    pub const fn classification(&self) -> DeltaSafety {
393        self.classification
394    }
395
396    /// Return deterministic fact-level reasons in operation order.
397    #[must_use]
398    pub fn reasons(&self) -> &[DeltaSafetyReason] {
399        &self.reasons
400    }
401}
402
403/// Classify one operation and fail malformed transitions closed as unsupported.
404#[must_use]
405pub fn classify_schema_operation_safety(operation: &SchemaOperation) -> DeltaSafety {
406    classify_operation_safety(operation).unwrap_or(DeltaSafety::Unsupported)
407}
408
409/// Classify a delta without consulting or producing an authorization decision.
410#[must_use]
411pub fn classify_delta_safety(delta: &SchemaDelta) -> DeltaSafetyReport {
412    let mut reasons = Vec::new();
413    for (operation_index, operation) in delta.operations().iter().enumerate() {
414        let classification = classify_schema_operation_safety(operation);
415        for fact_id in operation.affected_ids() {
416            reasons.push(DeltaSafetyReason {
417                operation_index,
418                fact_id,
419                classification,
420            });
421        }
422    }
423    let classification = reasons
424        .iter()
425        .map(DeltaSafetyReason::classification)
426        .max()
427        .unwrap_or(DeltaSafety::FormalOnly);
428    DeltaSafetyReport {
429        classification,
430        reasons,
431    }
432}