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