Skip to main content

type_bridge_schema_migration/
lowering.rs

1//! Deterministic offline lowering of validated schema deltas to TypeDB 3.12.1 TypeQL.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::error::Error;
5use std::fmt;
6
7use serde::Serialize;
8use type_bridge_contract::capability::{CapabilityId, CapabilitySet};
9use type_bridge_contract::id::{FunctionId, TypeKind};
10use type_bridge_contract::schema::{
11    AnnotationFact, AnnotationKindId, AnnotationSubjectId, FunctionFact, FunctionReturnElement,
12    FunctionReturnMode, RelatesFact, SchemaAnnotationValue, SchemaDelta, SchemaFact, SchemaFactId,
13    SchemaOperation, SchemaOperationKind, TypeReference,
14};
15use type_bridge_contract::value::{CanonicalValue, ValueTypeTag};
16
17use type_bridge_schema::SafetyClass;
18
19use crate::profile::{classify_operation_transition, typedb_3_12_1_profile};
20use crate::{SchemaLoweringProfileFingerprint, SchemaLoweringProfileId, profile_fingerprint};
21
22const CODE_PROFILE_MISMATCH: &str = "schema_lowering_profile_mismatch";
23const CODE_CAPABILITY_MISMATCH: &str = "schema_lowering_capability_mismatch";
24const CODE_CONTEXT_MISMATCH: &str = "schema_lowering_fact_context_mismatch";
25const CODE_REQUIRES_ASSERTION: &str = "schema_lowering_requires_assertion";
26const CODE_REQUIRES_BACKFILL: &str = "schema_lowering_requires_backfill";
27const CODE_DESTRUCTIVE: &str = "schema_lowering_destructive";
28const CODE_OPAQUE: &str = "schema_lowering_opaque";
29const CODE_UNSUPPORTED: &str = "schema_lowering_unsupported";
30const CODE_INVALID_TRANSITION: &str = "schema_lowering_invalid_transition";
31const CODE_RENDER_CONTEXT: &str = "schema_lowering_render_context_missing";
32
33/// Stable, provider-neutral failure returned before any provider I/O exists.
34#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
35pub struct SchemaLoweringDiagnostic {
36    code: &'static str,
37    message: &'static str,
38    operation_index: Option<usize>,
39    safety: Option<SafetyClass>,
40    missing_capabilities: Vec<CapabilityId>,
41}
42
43impl SchemaLoweringDiagnostic {
44    fn new(code: &'static str, message: &'static str) -> Self {
45        Self {
46            code,
47            message,
48            operation_index: None,
49            safety: None,
50            missing_capabilities: Vec::new(),
51        }
52    }
53
54    fn at_operation(mut self, operation_index: usize) -> Self {
55        self.operation_index = Some(operation_index);
56        self
57    }
58
59    fn with_safety(mut self, safety: SafetyClass) -> Self {
60        self.safety = Some(safety);
61        self
62    }
63
64    fn with_missing_capabilities(mut self, missing: Vec<CapabilityId>) -> Self {
65        self.missing_capabilities = missing;
66        self
67    }
68
69    /// Return the stable machine-readable diagnostic code.
70    pub const fn code(&self) -> &'static str {
71        self.code
72    }
73
74    /// Return the stable human-readable summary.
75    pub const fn message(&self) -> &'static str {
76        self.message
77    }
78
79    /// Return the outer delta-operation index, when applicable.
80    pub const fn operation_index(&self) -> Option<usize> {
81        self.operation_index
82    }
83
84    /// Return the safety class which stopped lowering, when applicable.
85    pub const fn safety(&self) -> Option<SafetyClass> {
86        self.safety
87    }
88
89    /// Return missing provider capabilities in deterministic order.
90    pub fn missing_capabilities(&self) -> &[CapabilityId] {
91        &self.missing_capabilities
92    }
93}
94
95impl fmt::Display for SchemaLoweringDiagnostic {
96    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
97        write!(formatter, "{}: {}", self.code, self.message)
98    }
99}
100
101impl Error for SchemaLoweringDiagnostic {}
102
103/// Exact fact payloads associated with one managed schema state.
104#[derive(Clone, Debug, Eq, PartialEq)]
105pub struct SchemaFactCatalog(BTreeMap<SchemaFactId, SchemaFact>);
106
107impl SchemaFactCatalog {
108    /// Build a deterministic catalog, rejecting duplicate identities.
109    pub fn new(
110        facts: impl IntoIterator<Item = SchemaFact>,
111    ) -> Result<Self, SchemaLoweringDiagnostic> {
112        let mut catalog = BTreeMap::new();
113        for fact in facts {
114            let id = fact.id();
115            if catalog.insert(id, fact).is_some() {
116                return Err(SchemaLoweringDiagnostic::new(
117                    CODE_CONTEXT_MISMATCH,
118                    "schema fact catalog contains a duplicate identity",
119                ));
120            }
121        }
122        Ok(Self(catalog))
123    }
124
125    /// Return an empty catalog.
126    pub fn empty() -> Self {
127        Self(BTreeMap::new())
128    }
129
130    /// Return one exact fact payload.
131    pub fn get(&self, id: &SchemaFactId) -> Option<&SchemaFact> {
132        self.0.get(id)
133    }
134
135    /// Iterate in canonical fact-identity order.
136    pub fn iter(&self) -> impl ExactSizeIterator<Item = (&SchemaFactId, &SchemaFact)> {
137        self.0.iter()
138    }
139
140    fn matches_selection(
141        &self,
142        selection: &type_bridge_contract::schema::ManagedFactSelection,
143    ) -> bool {
144        self.0.len() == selection.len()
145            && self
146                .0
147                .keys()
148                .zip(selection.iter())
149                .all(|(left, right)| left == right)
150    }
151}
152
153/// Fixed lowering-profile identity plus provider capabilities available to the caller.
154#[derive(Clone, Debug, Eq, PartialEq)]
155pub struct SchemaLoweringBinding {
156    profile_id: SchemaLoweringProfileId,
157    profile_fingerprint: SchemaLoweringProfileFingerprint,
158    available_capabilities: CapabilitySet,
159}
160
161impl SchemaLoweringBinding {
162    /// Bind caller capabilities to the exact compiled lowering profile.
163    pub fn new(
164        profile_id: SchemaLoweringProfileId,
165        profile_fingerprint: SchemaLoweringProfileFingerprint,
166        available_capabilities: CapabilitySet,
167    ) -> Result<Self, SchemaLoweringDiagnostic> {
168        let profile = typedb_3_12_1_profile();
169        if profile_id != profile.id || profile_fingerprint != crate::profile_fingerprint() {
170            return Err(SchemaLoweringDiagnostic::new(
171                CODE_PROFILE_MISMATCH,
172                "schema lowering profile identity or fingerprint does not match the compiled registry",
173            ));
174        }
175        Ok(Self {
176            profile_id,
177            profile_fingerprint,
178            available_capabilities,
179        })
180    }
181
182    /// Bind capabilities to the current compiled profile.
183    pub fn current(
184        available_capabilities: CapabilitySet,
185    ) -> Result<Self, SchemaLoweringDiagnostic> {
186        Self::new(
187            typedb_3_12_1_profile().id.clone(),
188            profile_fingerprint(),
189            available_capabilities,
190        )
191    }
192
193    /// Return available provider capabilities.
194    pub const fn available_capabilities(&self) -> &CapabilitySet {
195        &self.available_capabilities
196    }
197
198    /// Return the exact compiled lowering-profile identity.
199    pub const fn profile_id(&self) -> &SchemaLoweringProfileId {
200        &self.profile_id
201    }
202
203    /// Return the exact compiled lowering-profile content fingerprint.
204    pub const fn profile_fingerprint(&self) -> &SchemaLoweringProfileFingerprint {
205        &self.profile_fingerprint
206    }
207}
208
209/// TypeQL schema query verb.
210#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
211#[serde(rename_all = "snake_case")]
212pub enum TypeQlVerb {
213    Define,
214    Undefine,
215    Redefine,
216}
217
218impl TypeQlVerb {
219    fn as_str(self) -> &'static str {
220        match self {
221            Self::Define => "define",
222            Self::Undefine => "undefine",
223            Self::Redefine => "redefine",
224        }
225    }
226}
227
228/// One complete TypeQL schema query.
229#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
230pub struct TypeQlStatement {
231    verb: TypeQlVerb,
232    query: String,
233}
234
235impl TypeQlStatement {
236    fn new(verb: TypeQlVerb, body: String) -> Self {
237        Self {
238            verb,
239            query: format!("{}\n{body}", verb.as_str()),
240        }
241    }
242
243    /// Return the schema query verb.
244    pub const fn verb(&self) -> TypeQlVerb {
245        self.verb
246    }
247
248    /// Return exact deterministic query text.
249    pub fn query(&self) -> &str {
250        &self.query
251    }
252}
253
254/// Outer formal operation represented by a statement unit.
255#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
256#[serde(rename_all = "snake_case")]
257pub enum StatementOperationKind {
258    Define,
259    Redefine,
260    Undefine,
261}
262
263impl From<SchemaOperationKind> for StatementOperationKind {
264    fn from(value: SchemaOperationKind) -> Self {
265        match value {
266            SchemaOperationKind::Define => Self::Define,
267            SchemaOperationKind::Redefine => Self::Redefine,
268            SchemaOperationKind::Undefine => Self::Undefine,
269        }
270    }
271}
272
273/// One preserved outer delta operation and its atomic TypeQL query sequence.
274#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
275pub struct StatementUnit {
276    operation_index: usize,
277    operation_kind: StatementOperationKind,
278    safety: SafetyClass,
279    atomic: bool,
280    affected_ids: Vec<SchemaFactId>,
281    required_capabilities: CapabilitySet,
282    statements: Vec<TypeQlStatement>,
283}
284
285impl StatementUnit {
286    pub const fn operation_index(&self) -> usize {
287        self.operation_index
288    }
289
290    pub const fn operation_kind(&self) -> StatementOperationKind {
291        self.operation_kind
292    }
293
294    pub const fn safety(&self) -> SafetyClass {
295        self.safety
296    }
297
298    pub const fn atomic(&self) -> bool {
299        self.atomic
300    }
301
302    pub fn affected_ids(&self) -> &[SchemaFactId] {
303        &self.affected_ids
304    }
305
306    pub const fn required_capabilities(&self) -> &CapabilitySet {
307        &self.required_capabilities
308    }
309
310    pub fn statements(&self) -> &[TypeQlStatement] {
311        &self.statements
312    }
313}
314
315/// Provider-bound, safety-gated statement plan retaining its exact formal delta.
316#[derive(Clone, Debug, Eq, PartialEq)]
317pub struct SchemaLoweringPlan {
318    delta: SchemaDelta,
319    profile_id: SchemaLoweringProfileId,
320    profile_fingerprint: SchemaLoweringProfileFingerprint,
321    units: Vec<StatementUnit>,
322}
323
324impl SchemaLoweringPlan {
325    pub const fn delta(&self) -> &SchemaDelta {
326        &self.delta
327    }
328
329    pub const fn profile_id(&self) -> &SchemaLoweringProfileId {
330        &self.profile_id
331    }
332
333    pub const fn profile_fingerprint(&self) -> &SchemaLoweringProfileFingerprint {
334        &self.profile_fingerprint
335    }
336
337    pub fn units(&self) -> &[StatementUnit] {
338        &self.units
339    }
340}
341
342/// Lower a complete formal delta after validating its exact fact payload context.
343pub fn lower_schema_delta(
344    delta: &SchemaDelta,
345    source_facts: &SchemaFactCatalog,
346    target_facts: &SchemaFactCatalog,
347    binding: &SchemaLoweringBinding,
348) -> Result<SchemaLoweringPlan, SchemaLoweringDiagnostic> {
349    lower_schema_delta_with_verified_assertions(
350        delta,
351        source_facts,
352        target_facts,
353        binding,
354        &[],
355        false,
356    )
357}
358
359pub(crate) fn lower_schema_delta_with_verified_assertions(
360    delta: &SchemaDelta,
361    source_facts: &SchemaFactCatalog,
362    target_facts: &SchemaFactCatalog,
363    binding: &SchemaLoweringBinding,
364    conditional_operation_indices: &[usize],
365    destructive_approved: bool,
366) -> Result<SchemaLoweringPlan, SchemaLoweringDiagnostic> {
367    if !source_facts.matches_selection(delta.source().selection())
368        || !target_facts.matches_selection(delta.target().selection())
369    {
370        return Err(SchemaLoweringDiagnostic::new(
371            CODE_CONTEXT_MISMATCH,
372            "source or target fact catalog does not match the delta managed selection",
373        ));
374    }
375    if conditional_operation_indices
376        .windows(2)
377        .any(|pair| pair[0] >= pair[1])
378        || conditional_operation_indices
379            .last()
380            .is_some_and(|index| *index >= delta.operations().len())
381    {
382        return Err(SchemaLoweringDiagnostic::new(
383            CODE_CONTEXT_MISMATCH,
384            "verified conditional operation indices are not canonical for this delta",
385        ));
386    }
387    let units = delta
388        .operations()
389        .iter()
390        .enumerate()
391        .map(|(index, operation)| {
392            lower_operation(
393                index,
394                operation,
395                source_facts,
396                target_facts,
397                binding,
398                conditional_operation_indices.binary_search(&index).is_ok(),
399                destructive_approved,
400            )
401        })
402        .collect::<Result<Vec<_>, _>>()?;
403    Ok(SchemaLoweringPlan {
404        delta: delta.clone(),
405        profile_id: binding.profile_id.clone(),
406        profile_fingerprint: binding.profile_fingerprint.clone(),
407        units,
408    })
409}
410
411fn lower_operation(
412    operation_index: usize,
413    operation: &SchemaOperation,
414    source_facts: &SchemaFactCatalog,
415    target_facts: &SchemaFactCatalog,
416    binding: &SchemaLoweringBinding,
417    conditional_resolved: bool,
418    destructive_approved: bool,
419) -> Result<StatementUnit, SchemaLoweringDiagnostic> {
420    let classification = classify_operation_transition(operation).map_err(|error| {
421        SchemaLoweringDiagnostic::new(CODE_INVALID_TRANSITION, error.message())
422            .at_operation(operation_index)
423    })?;
424    let missing = classification
425        .required_capabilities
426        .iter()
427        .filter(|capability| !binding.available_capabilities.contains(capability))
428        .cloned()
429        .collect::<Vec<_>>();
430    if !missing.is_empty() {
431        return Err(SchemaLoweringDiagnostic::new(
432            CODE_CAPABILITY_MISMATCH,
433            "provider capabilities do not satisfy the schema lowering unit",
434        )
435        .at_operation(operation_index)
436        .with_missing_capabilities(missing));
437    }
438    gate_safety(
439        operation_index,
440        classification.safety,
441        conditional_resolved,
442        destructive_approved,
443    )?;
444    let statements = render_operation(operation_index, operation, source_facts, target_facts)?;
445    Ok(StatementUnit {
446        operation_index,
447        operation_kind: operation.kind().into(),
448        safety: classification.safety,
449        atomic: classification.atomic,
450        affected_ids: operation.affected_ids(),
451        required_capabilities: classification.required_capabilities,
452        statements,
453    })
454}
455
456fn gate_safety(
457    operation_index: usize,
458    safety: SafetyClass,
459    conditional_resolved: bool,
460    destructive_approved: bool,
461) -> Result<(), SchemaLoweringDiagnostic> {
462    if conditional_resolved && safety != SafetyClass::Conditional {
463        return Err(SchemaLoweringDiagnostic::new(
464            CODE_INVALID_TRANSITION,
465            "assertion coverage targets a non-conditional schema operation",
466        )
467        .at_operation(operation_index)
468        .with_safety(safety));
469    }
470    let (code, message) = match safety {
471        SafetyClass::FormalOnly | SafetyClass::SchemaMetadata | SafetyClass::Additive => {
472            return Ok(());
473        }
474        SafetyClass::Conditional if conditional_resolved => return Ok(()),
475        SafetyClass::Conditional => (
476            CODE_REQUIRES_ASSERTION,
477            "schema transition requires an explicit data assertion",
478        ),
479        SafetyClass::BackfillRequired => (
480            CODE_REQUIRES_BACKFILL,
481            "schema transition requires an explicit backfill plan",
482        ),
483        SafetyClass::Destructive if destructive_approved => return Ok(()),
484        SafetyClass::Destructive => (
485            CODE_DESTRUCTIVE,
486            "destructive schema transition requires an identity-bound approval",
487        ),
488        SafetyClass::Opaque => (
489            CODE_OPAQUE,
490            "opaque schema transition requires explicit operator intent",
491        ),
492        SafetyClass::Unsupported => (
493            CODE_UNSUPPORTED,
494            "schema transition is unsupported by the TypeDB 3.12.1 lowering profile",
495        ),
496    };
497    Err(SchemaLoweringDiagnostic::new(code, message)
498        .at_operation(operation_index)
499        .with_safety(safety))
500}
501
502#[derive(Debug)]
503struct RenderFailure {
504    code: &'static str,
505    message: &'static str,
506}
507
508fn render_operation(
509    operation_index: usize,
510    operation: &SchemaOperation,
511    source_facts: &SchemaFactCatalog,
512    target_facts: &SchemaFactCatalog,
513) -> Result<Vec<TypeQlStatement>, SchemaLoweringDiagnostic> {
514    render_operation_inner(operation, source_facts, target_facts).map_err(|failure| {
515        SchemaLoweringDiagnostic::new(failure.code, failure.message).at_operation(operation_index)
516    })
517}
518
519fn render_operation_inner(
520    operation: &SchemaOperation,
521    source_facts: &SchemaFactCatalog,
522    target_facts: &SchemaFactCatalog,
523) -> Result<Vec<TypeQlStatement>, RenderFailure> {
524    match operation.kind() {
525        SchemaOperationKind::Define => {
526            let facts = operation.defined_facts().expect("define exposes facts");
527            let function_ids = facts
528                .iter()
529                .filter_map(|fact| match fact {
530                    SchemaFact::Function(function) => Some(function.id().clone()),
531                    _ => None,
532                })
533                .collect::<BTreeSet<_>>();
534            let mut function_annotations = BTreeMap::<FunctionId, Vec<&AnnotationFact>>::new();
535            for fact in facts {
536                if let SchemaFact::Annotation(annotation) = fact
537                    && let AnnotationSubjectId::Function(function) = annotation.id().subject()
538                    && function_ids.contains(function)
539                {
540                    function_annotations
541                        .entry(function.clone())
542                        .or_default()
543                        .push(annotation);
544                }
545            }
546            let mut bodies = Vec::new();
547            for fact in facts {
548                match fact {
549                    SchemaFact::Annotation(annotation) if matches!(annotation.id().subject(), AnnotationSubjectId::Function(id) if function_ids.contains(id)) =>
550                        {}
551                    SchemaFact::Function(function) => bodies.push(render_function(
552                        function,
553                        function_annotations
554                            .get(function.id())
555                            .map(Vec::as_slice)
556                            .unwrap_or_default(),
557                    )?),
558                    _ => bodies.push(render_definition(fact, target_facts, true)?),
559                }
560            }
561            Ok(vec![TypeQlStatement::new(
562                TypeQlVerb::Define,
563                bodies.join("\n"),
564            )])
565        }
566        SchemaOperationKind::Undefine => Ok(vec![TypeQlStatement::new(
567            TypeQlVerb::Undefine,
568            render_undefinition(
569                operation.undefined_fact().expect("undefine exposes fact"),
570                source_facts,
571            )?,
572        )]),
573        SchemaOperationKind::Redefine => {
574            let expected = operation
575                .expected_fact()
576                .expect("redefine exposes expected");
577            let replacement = operation
578                .replacement_fact()
579                .expect("redefine exposes replacement");
580            if let (SchemaFact::Annotation(old), SchemaFact::Annotation(new)) =
581                (expected, replacement)
582                && matches!(old.id().subject(), AnnotationSubjectId::Sub(_))
583                && matches!(
584                    old.id().kind(),
585                    AnnotationKindId::Doc | AnnotationKindId::Meta(_)
586                )
587            {
588                return Ok(vec![
589                    TypeQlStatement::new(
590                        TypeQlVerb::Undefine,
591                        render_annotation_undefinition(old, source_facts)?,
592                    ),
593                    TypeQlStatement::new(
594                        TypeQlVerb::Define,
595                        render_annotation_definition(new, target_facts, false)?,
596                    ),
597                ]);
598            }
599            if let (SchemaFact::Relates(old), SchemaFact::Relates(new)) = (expected, replacement) {
600                return match (old.specializes(), new.specializes()) {
601                    (None, Some(_)) => Ok(vec![TypeQlStatement::new(
602                        TypeQlVerb::Define,
603                        render_relates(new),
604                    )]),
605                    (Some(old_parent), None) => Ok(vec![TypeQlStatement::new(
606                        TypeQlVerb::Undefine,
607                        format!(
608                            "as {} from {} relates {};",
609                            old_parent.label().as_str(),
610                            old.id().relation().label().as_str(),
611                            old.id().role().label().as_str()
612                        ),
613                    )]),
614                    (Some(_), Some(_)) => Ok(vec![TypeQlStatement::new(
615                        TypeQlVerb::Redefine,
616                        render_relates(new),
617                    )]),
618                    (None, None) => Err(RenderFailure {
619                        code: CODE_INVALID_TRANSITION,
620                        message: "relates redefinition does not change specialization",
621                    }),
622                };
623            }
624            Ok(vec![TypeQlStatement::new(
625                TypeQlVerb::Redefine,
626                render_definition(replacement, target_facts, false)?,
627            )])
628        }
629    }
630}
631
632fn render_definition(
633    fact: &SchemaFact,
634    catalog: &SchemaFactCatalog,
635    defining: bool,
636) -> Result<String, RenderFailure> {
637    match fact {
638        SchemaFact::Type(fact) => Ok(format!(
639            "{} {};",
640            type_kind(fact.id().kind()),
641            fact.id().label().as_str()
642        )),
643        SchemaFact::Sub(fact) => Ok(format!(
644            "{} sub {};",
645            fact.id().subtype().label().as_str(),
646            fact.id().supertype().label().as_str()
647        )),
648        SchemaFact::Value(fact) => Ok(format!(
649            "{} value {};",
650            fact.id().attribute().label().as_str(),
651            value_type(fact.value_type())
652        )),
653        SchemaFact::Owns(fact) => Ok(format!(
654            "{} owns {};",
655            fact.id().owner().label().as_str(),
656            fact.id().attribute().label().as_str()
657        )),
658        SchemaFact::Relates(fact) => Ok(render_relates(fact)),
659        SchemaFact::Plays(fact) => Ok(format!(
660            "{} plays {}:{};",
661            fact.id().player().label().as_str(),
662            fact.id().role().declaring_relation().as_str(),
663            fact.id().role().label().as_str()
664        )),
665        SchemaFact::Annotation(fact) => render_annotation_definition(fact, catalog, defining),
666        SchemaFact::Function(fact) => render_function(fact, &[]),
667        SchemaFact::Struct(_) => Err(RenderFailure {
668            code: CODE_UNSUPPORTED,
669            message: "TypeDB 3.12.1 does not admit the pinned struct transition grammar",
670        }),
671    }
672}
673
674fn render_undefinition(
675    fact: &SchemaFact,
676    catalog: &SchemaFactCatalog,
677) -> Result<String, RenderFailure> {
678    match fact {
679        // Type deletion takes the bare label: the kind keyword belongs to
680        // the define grammar only.
681        SchemaFact::Type(fact) => Ok(format!("{};", fact.id().label().as_str())),
682        SchemaFact::Sub(fact) => Ok(format!(
683            "sub {} from {};",
684            fact.id().supertype().label().as_str(),
685            fact.id().subtype().label().as_str()
686        )),
687        SchemaFact::Value(fact) => Ok(format!(
688            "value {} from {};",
689            value_type(fact.value_type()),
690            fact.id().attribute().label().as_str()
691        )),
692        SchemaFact::Owns(fact) => Ok(format!(
693            "owns {} from {};",
694            fact.id().attribute().label().as_str(),
695            fact.id().owner().label().as_str()
696        )),
697        SchemaFact::Relates(fact) => Ok(format!(
698            "relates {} from {};",
699            fact.id().role().label().as_str(),
700            fact.id().relation().label().as_str()
701        )),
702        SchemaFact::Plays(fact) => Ok(format!(
703            "plays {}:{} from {};",
704            fact.id().role().declaring_relation().as_str(),
705            fact.id().role().label().as_str(),
706            fact.id().player().label().as_str()
707        )),
708        SchemaFact::Annotation(fact) => render_annotation_undefinition(fact, catalog),
709        SchemaFact::Function(fact) => Ok(format!("fun {};", fact.id().label().as_str())),
710        SchemaFact::Struct(_) => Err(RenderFailure {
711            code: CODE_UNSUPPORTED,
712            message: "TypeDB 3.12.1 does not admit the pinned struct transition grammar",
713        }),
714    }
715}
716
717fn render_relates(fact: &RelatesFact) -> String {
718    let specializes = fact
719        .specializes()
720        .map(|role| format!(" as {}", role.label().as_str()))
721        .unwrap_or_default();
722    format!(
723        "{} relates {}{};",
724        fact.id().relation().label().as_str(),
725        fact.id().role().label().as_str(),
726        specializes
727    )
728}
729
730fn render_annotation_definition(
731    annotation: &AnnotationFact,
732    catalog: &SchemaFactCatalog,
733    defining: bool,
734) -> Result<String, RenderFailure> {
735    let subject = render_annotation_subject(annotation.id().subject(), catalog, defining)?;
736    Ok(format!("{subject} {};", render_annotation(annotation)))
737}
738
739fn render_annotation_undefinition(
740    annotation: &AnnotationFact,
741    catalog: &SchemaFactCatalog,
742) -> Result<String, RenderFailure> {
743    let subject = render_annotation_subject(annotation.id().subject(), catalog, false)?;
744    Ok(format!(
745        "{} from {subject};",
746        render_annotation_selector(annotation.id().kind())
747    ))
748}
749
750fn render_annotation_subject(
751    subject: &AnnotationSubjectId,
752    catalog: &SchemaFactCatalog,
753    defining: bool,
754) -> Result<String, RenderFailure> {
755    match subject {
756        AnnotationSubjectId::Type(id) if defining => {
757            Ok(format!("{} {}", type_kind(id.kind()), id.label().as_str()))
758        }
759        AnnotationSubjectId::Type(id) => Ok(id.label().as_str().to_owned()),
760        AnnotationSubjectId::Sub(id) => Ok(format!(
761            "{} sub {}",
762            id.subtype().label().as_str(),
763            id.supertype().label().as_str()
764        )),
765        AnnotationSubjectId::Value(id) => {
766            let fact_id = SchemaFactId::Value(id.clone());
767            let Some(SchemaFact::Value(value)) = catalog.get(&fact_id) else {
768                return Err(RenderFailure {
769                    code: CODE_RENDER_CONTEXT,
770                    message: "value annotation rendering requires its exact value fact payload",
771                });
772            };
773            Ok(format!(
774                "{} value {}",
775                id.attribute().label().as_str(),
776                value_type(value.value_type())
777            ))
778        }
779        AnnotationSubjectId::Owns(id) => Ok(format!(
780            "{} owns {}",
781            id.owner().label().as_str(),
782            id.attribute().label().as_str()
783        )),
784        AnnotationSubjectId::Relates(id) => Ok(format!(
785            "{} relates {}",
786            id.relation().label().as_str(),
787            id.role().label().as_str()
788        )),
789        AnnotationSubjectId::Plays(id) => Ok(format!(
790            "{} plays {}:{}",
791            id.player().label().as_str(),
792            id.role().declaring_relation().as_str(),
793            id.role().label().as_str()
794        )),
795        AnnotationSubjectId::Function(_) => Err(RenderFailure {
796            code: CODE_UNSUPPORTED,
797            message: "persistent function annotations are unsupported; fold metadata into function definition",
798        }),
799    }
800}
801
802fn render_annotation(annotation: &AnnotationFact) -> String {
803    match (annotation.id().kind(), annotation.value()) {
804        (AnnotationKindId::Abstract, SchemaAnnotationValue::Presence) => "@abstract".into(),
805        (AnnotationKindId::Independent, SchemaAnnotationValue::Presence) => "@independent".into(),
806        (AnnotationKindId::Key, SchemaAnnotationValue::Presence) => "@key".into(),
807        (AnnotationKindId::Unique, SchemaAnnotationValue::Presence) => "@unique".into(),
808        (AnnotationKindId::Card, SchemaAnnotationValue::Cardinality(cardinality)) => format!(
809            "@card({}..{})",
810            (*cardinality).min(),
811            (*cardinality)
812                .max()
813                .map(|value| value.to_string())
814                .unwrap_or_default()
815        ),
816        (AnnotationKindId::Regex, SchemaAnnotationValue::Regex(regex)) => {
817            format!("@regex({})", quote(regex.as_str()))
818        }
819        (AnnotationKindId::Range, SchemaAnnotationValue::Range(range)) => format!(
820            "@range({}..{})",
821            range.lower().map(render_value).unwrap_or_default(),
822            range.upper().map(render_value).unwrap_or_default()
823        ),
824        (AnnotationKindId::Values, SchemaAnnotationValue::Values(values)) => format!(
825            "@values({})",
826            values
827                .iter()
828                .map(render_value)
829                .collect::<Vec<_>>()
830                .join(", ")
831        ),
832        (AnnotationKindId::Doc, SchemaAnnotationValue::Doc(doc)) => {
833            format!("@doc({})", quote(doc.as_str()))
834        }
835        (AnnotationKindId::Meta(key), SchemaAnnotationValue::Meta(value)) => {
836            format!("@meta({}, {})", quote(key.as_str()), render_value(value))
837        }
838        _ => unreachable!("annotation constructors preserve kind-safe payloads"),
839    }
840}
841
842fn render_annotation_selector(kind: &AnnotationKindId) -> String {
843    match kind {
844        AnnotationKindId::Abstract => "@abstract".into(),
845        AnnotationKindId::Independent => "@independent".into(),
846        AnnotationKindId::Key => "@key".into(),
847        AnnotationKindId::Unique => "@unique".into(),
848        AnnotationKindId::Card => "@card".into(),
849        AnnotationKindId::Regex => "@regex".into(),
850        AnnotationKindId::Range => "@range".into(),
851        AnnotationKindId::Values => "@values".into(),
852        AnnotationKindId::Doc => "@doc".into(),
853        AnnotationKindId::Meta(key) => format!("@meta({})", quote(key.as_str())),
854    }
855}
856
857fn render_function(
858    function: &FunctionFact,
859    annotations: &[&AnnotationFact],
860) -> Result<String, RenderFailure> {
861    let parameters = function
862        .signature()
863        .parameters()
864        .iter()
865        .map(|parameter| {
866            format!(
867                "${}: {}",
868                parameter.name().as_str(),
869                render_type_reference(parameter.type_ref())
870            )
871        })
872        .collect::<Vec<_>>()
873        .join(", ");
874    let returns = match function.signature().returns() {
875        FunctionReturnMode::Scalar(element) => render_return_element(element),
876        FunctionReturnMode::Tuple(elements) => format!(
877            "({})",
878            elements
879                .iter()
880                .map(render_return_element)
881                .collect::<Vec<_>>()
882                .join(", ")
883        ),
884        FunctionReturnMode::Stream(elements) => format!(
885            "{{ {} }}",
886            elements
887                .iter()
888                .map(render_return_element)
889                .collect::<Vec<_>>()
890                .join(", ")
891        ),
892    };
893    let mut rendered_annotations = annotations
894        .iter()
895        .map(|annotation| render_annotation(annotation))
896        .collect::<Vec<_>>();
897    rendered_annotations.sort();
898    let suffix = if rendered_annotations.is_empty() {
899        String::new()
900    } else {
901        format!(" {}", rendered_annotations.join(" "))
902    };
903    Ok(format!(
904        "fun {}({parameters}) -> {returns}{suffix}:\n{}",
905        function.id().label().as_str(),
906        function.body().text()
907    ))
908}
909
910fn render_return_element(element: &FunctionReturnElement) -> String {
911    format!(
912        "{}{}",
913        render_type_reference(element.type_ref()),
914        if element.optional() { "?" } else { "" }
915    )
916}
917
918fn render_type_reference(reference: &TypeReference) -> String {
919    match reference {
920        TypeReference::Value(value) => value_type(*value).into(),
921        TypeReference::Schema(label) => label.as_str().into(),
922    }
923}
924
925fn render_value(value: &CanonicalValue) -> String {
926    match value {
927        CanonicalValue::String(value) => quote(value.as_str()),
928        CanonicalValue::Long(value) => value.to_string(),
929        CanonicalValue::Double(value) => format!("{:?}", value.get()),
930        CanonicalValue::Boolean(value) => value.to_string(),
931        CanonicalValue::Date(value) => value.to_string(),
932        CanonicalValue::DateTime(value) => value.to_string(),
933        CanonicalValue::DateTimeTz(value) => value.to_string(),
934        CanonicalValue::Decimal(value) => format!("{}dec", value.as_str()),
935        CanonicalValue::Duration(value) => value.to_string(),
936    }
937}
938
939fn quote(value: &str) -> String {
940    serde_json::to_string(value).expect("strings always serialize")
941}
942
943fn type_kind(kind: TypeKind) -> &'static str {
944    match kind {
945        TypeKind::Entity => "entity",
946        TypeKind::Relation => "relation",
947        TypeKind::Attribute => "attribute",
948        TypeKind::Struct => "struct",
949    }
950}
951
952fn value_type(value: ValueTypeTag) -> &'static str {
953    match value.as_str() {
954        "long" => "integer",
955        "datetime_tz" => "datetime-tz",
956        other => other,
957    }
958}
959
960#[cfg(test)]
961mod tests {
962    use super::*;
963    use type_bridge_contract::id::{AttributeId, Label, RoleId, StructId, TypeId};
964    use type_bridge_contract::schema::{
965        AnnotationFactId, CanonicalValueRange, CanonicalValueSet, DocText, FunctionBody,
966        FunctionParameter, FunctionSignature, OwnsFact, OwnsFactId, PlaysFact, PlaysFactId,
967        RegexPattern, RelatesFactId, SchemaOperation, StructFact, StructField, SubFact, SubFactId,
968        TypeFact, ValueFact, ValueFactId,
969    };
970    use type_bridge_contract::value::{CanonicalString, Cardinality};
971
972    fn type_id(kind: TypeKind, label: &str) -> TypeId {
973        TypeId::new(kind, label).unwrap()
974    }
975
976    fn attribute_id(label: &str) -> AttributeId {
977        AttributeId::new(label).unwrap()
978    }
979
980    fn role_id(relation: &str, role: &str) -> RoleId {
981        RoleId::new(relation, role).unwrap()
982    }
983
984    fn value_fact(label: &str, value_type: ValueTypeTag) -> SchemaFact {
985        SchemaFact::Value(ValueFact::new(
986            ValueFactId::new(attribute_id(label)),
987            value_type,
988        ))
989    }
990
991    fn annotation(
992        subject: AnnotationSubjectId,
993        kind: AnnotationKindId,
994        value: SchemaAnnotationValue,
995    ) -> SchemaFact {
996        SchemaFact::Annotation(
997            AnnotationFact::new(AnnotationFactId::new(subject, kind), value).unwrap(),
998        )
999    }
1000
1001    fn function(body: &str) -> SchemaFact {
1002        SchemaFact::Function(FunctionFact::new(
1003            FunctionId::new("answer").unwrap(),
1004            FunctionSignature::new(
1005                vec![FunctionParameter::new(
1006                    Label::new("seed").unwrap(),
1007                    TypeReference::Value(ValueTypeTag::Long),
1008                )],
1009                FunctionReturnMode::scalar(FunctionReturnElement::new(
1010                    TypeReference::Value(ValueTypeTag::Long),
1011                    false,
1012                )),
1013            )
1014            .unwrap(),
1015            FunctionBody::new(body).unwrap(),
1016        ))
1017    }
1018
1019    fn full_binding() -> SchemaLoweringBinding {
1020        SchemaLoweringBinding::current(typedb_3_12_1_profile().required_capabilities.clone())
1021            .unwrap()
1022    }
1023
1024    fn dump(
1025        name: &str,
1026        operation: SchemaOperation,
1027        source: &SchemaFactCatalog,
1028        target: &SchemaFactCatalog,
1029        output: &mut String,
1030    ) {
1031        output.push_str("## ");
1032        output.push_str(name);
1033        output.push('\n');
1034        for (index, statement) in render_operation_inner(&operation, source, target)
1035            .unwrap()
1036            .iter()
1037            .enumerate()
1038        {
1039            if index != 0 {
1040                output.push_str("-- atomic-next --\n");
1041            }
1042            output.push_str(statement.query());
1043            output.push('\n');
1044        }
1045    }
1046
1047    #[test]
1048    fn supported_renderer_matches_exhaustive_golden() {
1049        let empty = SchemaFactCatalog::empty();
1050        let person = type_id(TypeKind::Entity, "person");
1051        let employee = type_id(TypeKind::Entity, "employee");
1052        let name = attribute_id("name");
1053        let owns_id = OwnsFactId::new(person.clone(), name.clone()).unwrap();
1054        let relation = type_id(TypeKind::Relation, "friendship");
1055        let role = role_id("friendship", "friend");
1056        let relates_id = RelatesFactId::new(relation.clone(), role.clone()).unwrap();
1057        let child_relation = type_id(TypeKind::Relation, "child-relation");
1058        let child_role = role_id("child-relation", "child-role");
1059        let child_relates = RelatesFactId::new(child_relation.clone(), child_role.clone()).unwrap();
1060        let parent_a = role_id("parent-relation", "parent-role-a");
1061        let parent_b = role_id("parent-relation", "parent-role-b");
1062        let plays_id = PlaysFactId::new(person.clone(), role.clone()).unwrap();
1063        let sub_id = SubFactId::new(employee.clone(), person.clone()).unwrap();
1064        let string_value = value_fact("name", ValueTypeTag::String);
1065        let integer_value = value_fact("name", ValueTypeTag::Long);
1066        let string_catalog = SchemaFactCatalog::new([string_value.clone()]).unwrap();
1067        let integer_catalog = SchemaFactCatalog::new([integer_value.clone()]).unwrap();
1068        let mut output = String::new();
1069
1070        let type_fact = SchemaFact::Type(TypeFact::new(person.clone()).unwrap());
1071        dump(
1072            "type-define",
1073            SchemaOperation::define(vec![type_fact.clone()]).unwrap(),
1074            &empty,
1075            &empty,
1076            &mut output,
1077        );
1078        dump(
1079            "type-undefine",
1080            SchemaOperation::undefine(type_fact),
1081            &empty,
1082            &empty,
1083            &mut output,
1084        );
1085        let sub_fact = SchemaFact::Sub(SubFact::new(sub_id.clone()));
1086        dump(
1087            "sub-define",
1088            SchemaOperation::define(vec![sub_fact.clone()]).unwrap(),
1089            &empty,
1090            &empty,
1091            &mut output,
1092        );
1093        dump(
1094            "sub-undefine",
1095            SchemaOperation::undefine(sub_fact),
1096            &empty,
1097            &empty,
1098            &mut output,
1099        );
1100        dump(
1101            "value-define",
1102            SchemaOperation::define(vec![string_value.clone()]).unwrap(),
1103            &empty,
1104            &string_catalog,
1105            &mut output,
1106        );
1107        dump(
1108            "value-redefine",
1109            SchemaOperation::redefine(string_value.clone(), integer_value.clone()).unwrap(),
1110            &string_catalog,
1111            &integer_catalog,
1112            &mut output,
1113        );
1114        dump(
1115            "value-undefine",
1116            SchemaOperation::undefine(string_value.clone()),
1117            &string_catalog,
1118            &empty,
1119            &mut output,
1120        );
1121        let owns = SchemaFact::Owns(OwnsFact::new(owns_id.clone()));
1122        dump(
1123            "owns-define",
1124            SchemaOperation::define(vec![owns.clone()]).unwrap(),
1125            &empty,
1126            &empty,
1127            &mut output,
1128        );
1129        dump(
1130            "owns-undefine",
1131            SchemaOperation::undefine(owns),
1132            &empty,
1133            &empty,
1134            &mut output,
1135        );
1136        let relates = SchemaFact::Relates(RelatesFact::new(relates_id, None).unwrap());
1137        dump(
1138            "relates-define",
1139            SchemaOperation::define(vec![relates.clone()]).unwrap(),
1140            &empty,
1141            &empty,
1142            &mut output,
1143        );
1144        dump(
1145            "relates-undefine",
1146            SchemaOperation::undefine(relates),
1147            &empty,
1148            &empty,
1149            &mut output,
1150        );
1151        let specialization_a = SchemaFact::Relates(
1152            RelatesFact::new(child_relates.clone(), Some(parent_a.clone())).unwrap(),
1153        );
1154        let specialization_b = SchemaFact::Relates(
1155            RelatesFact::new(child_relates.clone(), Some(parent_b.clone())).unwrap(),
1156        );
1157        let unspecialized = SchemaFact::Relates(RelatesFact::new(child_relates, None).unwrap());
1158        dump(
1159            "specialization-define",
1160            SchemaOperation::redefine(unspecialized.clone(), specialization_a.clone()).unwrap(),
1161            &empty,
1162            &empty,
1163            &mut output,
1164        );
1165        dump(
1166            "specialization-redefine",
1167            SchemaOperation::redefine(specialization_a.clone(), specialization_b.clone()).unwrap(),
1168            &empty,
1169            &empty,
1170            &mut output,
1171        );
1172        dump(
1173            "specialization-undefine",
1174            SchemaOperation::redefine(specialization_b, unspecialized).unwrap(),
1175            &empty,
1176            &empty,
1177            &mut output,
1178        );
1179        let plays = SchemaFact::Plays(PlaysFact::new(plays_id));
1180        dump(
1181            "plays-define",
1182            SchemaOperation::define(vec![plays.clone()]).unwrap(),
1183            &empty,
1184            &empty,
1185            &mut output,
1186        );
1187        dump(
1188            "plays-undefine",
1189            SchemaOperation::undefine(plays),
1190            &empty,
1191            &empty,
1192            &mut output,
1193        );
1194
1195        let doc_old = annotation(
1196            AnnotationSubjectId::Type(person.clone()),
1197            AnnotationKindId::Doc,
1198            SchemaAnnotationValue::Doc(DocText::new("line\n\"quoted\"").unwrap()),
1199        );
1200        let doc_new = annotation(
1201            AnnotationSubjectId::Type(person.clone()),
1202            AnnotationKindId::Doc,
1203            SchemaAnnotationValue::Doc(DocText::new("changed").unwrap()),
1204        );
1205        dump(
1206            "doc-define",
1207            SchemaOperation::define(vec![doc_old.clone()]).unwrap(),
1208            &empty,
1209            &empty,
1210            &mut output,
1211        );
1212        dump(
1213            "doc-redefine",
1214            SchemaOperation::redefine(doc_old.clone(), doc_new).unwrap(),
1215            &empty,
1216            &empty,
1217            &mut output,
1218        );
1219        dump(
1220            "doc-undefine",
1221            SchemaOperation::undefine(doc_old),
1222            &empty,
1223            &empty,
1224            &mut output,
1225        );
1226        let regex_old = annotation(
1227            AnnotationSubjectId::Value(ValueFactId::new(name.clone())),
1228            AnnotationKindId::Regex,
1229            SchemaAnnotationValue::Regex(RegexPattern::new("^a+\\\\d$").unwrap()),
1230        );
1231        let regex_new = annotation(
1232            AnnotationSubjectId::Value(ValueFactId::new(name.clone())),
1233            AnnotationKindId::Regex,
1234            SchemaAnnotationValue::Regex(RegexPattern::new("^b+$").unwrap()),
1235        );
1236        dump(
1237            "regex-redefine",
1238            SchemaOperation::redefine(regex_old, regex_new).unwrap(),
1239            &string_catalog,
1240            &string_catalog,
1241            &mut output,
1242        );
1243        let card_old = annotation(
1244            AnnotationSubjectId::Owns(owns_id.clone()),
1245            AnnotationKindId::Card,
1246            SchemaAnnotationValue::Cardinality(Cardinality::new(0, Some(1)).unwrap()),
1247        );
1248        let card_new = annotation(
1249            AnnotationSubjectId::Owns(owns_id),
1250            AnnotationKindId::Card,
1251            SchemaAnnotationValue::Cardinality(Cardinality::new(0, Some(2)).unwrap()),
1252        );
1253        dump(
1254            "card-redefine",
1255            SchemaOperation::redefine(card_old, card_new).unwrap(),
1256            &empty,
1257            &empty,
1258            &mut output,
1259        );
1260        let range = annotation(
1261            AnnotationSubjectId::Value(ValueFactId::new(name.clone())),
1262            AnnotationKindId::Range,
1263            SchemaAnnotationValue::Range(
1264                CanonicalValueRange::new(
1265                    Some(CanonicalValue::Long(1)),
1266                    Some(CanonicalValue::Long(10)),
1267                )
1268                .unwrap(),
1269            ),
1270        );
1271        dump(
1272            "range-define",
1273            SchemaOperation::define(vec![range]).unwrap(),
1274            &empty,
1275            &string_catalog,
1276            &mut output,
1277        );
1278        let values = annotation(
1279            AnnotationSubjectId::Value(ValueFactId::new(name)),
1280            AnnotationKindId::Values,
1281            SchemaAnnotationValue::Values(
1282                CanonicalValueSet::new([CanonicalValue::Long(2), CanonicalValue::Long(1)]).unwrap(),
1283            ),
1284        );
1285        dump(
1286            "values-define",
1287            SchemaOperation::define(vec![values]).unwrap(),
1288            &empty,
1289            &integer_catalog,
1290            &mut output,
1291        );
1292        let meta_old = annotation(
1293            AnnotationSubjectId::Sub(sub_id.clone()),
1294            AnnotationKindId::meta("owner").unwrap(),
1295            SchemaAnnotationValue::Meta(CanonicalValue::String(
1296                CanonicalString::new("old").unwrap(),
1297            )),
1298        );
1299        let meta_new = annotation(
1300            AnnotationSubjectId::Sub(sub_id),
1301            AnnotationKindId::meta("owner").unwrap(),
1302            SchemaAnnotationValue::Meta(CanonicalValue::String(
1303                CanonicalString::new("new").unwrap(),
1304            )),
1305        );
1306        dump(
1307            "sub-meta-fallback",
1308            SchemaOperation::redefine(meta_old.clone(), meta_new).unwrap(),
1309            &empty,
1310            &empty,
1311            &mut output,
1312        );
1313        dump(
1314            "meta-keyed-undefine",
1315            SchemaOperation::undefine(meta_old),
1316            &empty,
1317            &empty,
1318            &mut output,
1319        );
1320
1321        let function_fact = function("match\n  let $value = $seed;\nreturn first $value;");
1322        let function_doc = annotation(
1323            AnnotationSubjectId::Function(FunctionId::new("answer").unwrap()),
1324            AnnotationKindId::Doc,
1325            SchemaAnnotationValue::Doc(DocText::new("answer docs").unwrap()),
1326        );
1327        let function_meta = annotation(
1328            AnnotationSubjectId::Function(FunctionId::new("answer").unwrap()),
1329            AnnotationKindId::meta("owner").unwrap(),
1330            SchemaAnnotationValue::Meta(CanonicalValue::String(
1331                CanonicalString::new("core").unwrap(),
1332            )),
1333        );
1334        dump(
1335            "function-define-with-metadata",
1336            SchemaOperation::define(vec![function_meta, function_fact.clone(), function_doc])
1337                .unwrap(),
1338            &empty,
1339            &empty,
1340            &mut output,
1341        );
1342        let changed_function = function("match\n  let $value = 2;\nreturn first $value;");
1343        dump(
1344            "function-redefine",
1345            SchemaOperation::redefine(function_fact.clone(), changed_function).unwrap(),
1346            &empty,
1347            &empty,
1348            &mut output,
1349        );
1350        dump(
1351            "function-undefine",
1352            SchemaOperation::undefine(function_fact),
1353            &empty,
1354            &empty,
1355            &mut output,
1356        );
1357
1358        assert_eq!(
1359            output,
1360            include_str!("../tests/fixtures/lowering-supported-v1.txt")
1361        );
1362    }
1363
1364    #[test]
1365    fn safety_profile_and_capability_rejections_match_golden() {
1366        let empty = SchemaFactCatalog::empty();
1367        let full = full_binding();
1368        let person = type_id(TypeKind::Entity, "person");
1369        let employee = type_id(TypeKind::Entity, "employee");
1370        let sub = SchemaFact::Sub(SubFact::new(
1371            SubFactId::new(employee, person.clone()).unwrap(),
1372        ));
1373        let name = attribute_id("name");
1374        let owns_id = OwnsFactId::new(person.clone(), name).unwrap();
1375        let key = annotation(
1376            AnnotationSubjectId::Owns(owns_id),
1377            AnnotationKindId::Key,
1378            SchemaAnnotationValue::Presence,
1379        );
1380        let type_fact = SchemaFact::Type(TypeFact::new(person).unwrap());
1381        let function_old = function("match\n  let $value = 1;\nreturn first $value;");
1382        let function_new = function("match\n  let $value = 2;\nreturn first $value;");
1383        let struct_fact = SchemaFact::Struct(
1384            StructFact::new(
1385                StructId::new("record").unwrap(),
1386                vec![StructField::new(
1387                    Label::new("field").unwrap(),
1388                    ValueTypeTag::Long,
1389                    false,
1390                )],
1391            )
1392            .unwrap(),
1393        );
1394        let persistent_function_doc = annotation(
1395            AnnotationSubjectId::Function(FunctionId::new("answer").unwrap()),
1396            AnnotationKindId::Doc,
1397            SchemaAnnotationValue::Doc(DocText::new("docs").unwrap()),
1398        );
1399        let cases = [
1400            (
1401                "conditional",
1402                SchemaOperation::define(vec![sub]).unwrap(),
1403                &full,
1404            ),
1405            (
1406                "backfill",
1407                SchemaOperation::define(vec![key]).unwrap(),
1408                &full,
1409            ),
1410            (
1411                "destructive",
1412                SchemaOperation::undefine(type_fact.clone()),
1413                &full,
1414            ),
1415            (
1416                "opaque",
1417                SchemaOperation::redefine(function_old, function_new).unwrap(),
1418                &full,
1419            ),
1420            (
1421                "struct-unsupported",
1422                SchemaOperation::define(vec![struct_fact]).unwrap(),
1423                &full,
1424            ),
1425            (
1426                "persistent-function-metadata",
1427                SchemaOperation::define(vec![persistent_function_doc]).unwrap(),
1428                &full,
1429            ),
1430        ];
1431        let mut output = String::new();
1432        for (name, operation, binding) in cases {
1433            let error =
1434                lower_operation(0, &operation, &empty, &empty, binding, false, false).unwrap_err();
1435            output.push_str(name);
1436            output.push('|');
1437            output.push_str(error.code());
1438            output.push('\n');
1439        }
1440        let no_capabilities = SchemaLoweringBinding::current(CapabilitySet::new()).unwrap();
1441        let capability_error = lower_operation(
1442            0,
1443            &SchemaOperation::define(vec![type_fact]).unwrap(),
1444            &empty,
1445            &empty,
1446            &no_capabilities,
1447            false,
1448            false,
1449        )
1450        .unwrap_err();
1451        output.push_str("capability|");
1452        output.push_str(capability_error.code());
1453        output.push('\n');
1454        let profile_error = SchemaLoweringBinding::new(
1455            SchemaLoweringProfileId::typedb_3_12_1(),
1456            SchemaLoweringProfileFingerprint::compute(b"wrong profile bytes"),
1457            CapabilitySet::new(),
1458        )
1459        .unwrap_err();
1460        output.push_str("profile|");
1461        output.push_str(profile_error.code());
1462        output.push('\n');
1463        assert_eq!(
1464            output,
1465            include_str!("../tests/fixtures/lowering-rejections-v1.txt")
1466        );
1467    }
1468
1469    #[test]
1470    fn equal_default_cardinality_is_formal_only_and_lowers() {
1471        let person = type_id(TypeKind::Entity, "person");
1472        let owns = OwnsFactId::new(person, attribute_id("name")).unwrap();
1473        let card = annotation(
1474            AnnotationSubjectId::Owns(owns),
1475            AnnotationKindId::Card,
1476            SchemaAnnotationValue::Cardinality(Cardinality::new(0, Some(1)).unwrap()),
1477        );
1478        let unit = lower_operation(
1479            0,
1480            &SchemaOperation::undefine(card),
1481            &SchemaFactCatalog::empty(),
1482            &SchemaFactCatalog::empty(),
1483            &full_binding(),
1484            false,
1485            false,
1486        )
1487        .unwrap();
1488        assert_eq!(unit.safety(), SafetyClass::FormalOnly);
1489        assert_eq!(
1490            unit.statements()[0].query(),
1491            "undefine\n@card from person owns name;"
1492        );
1493    }
1494
1495    #[test]
1496    fn safety_gate_matches_exhaustive_golden() {
1497        let mut output = String::new();
1498        for safety in SafetyClass::ALL {
1499            let name = match safety {
1500                SafetyClass::FormalOnly => "formal_only",
1501                SafetyClass::SchemaMetadata => "schema_metadata",
1502                SafetyClass::Additive => "additive",
1503                SafetyClass::Conditional => "conditional",
1504                SafetyClass::BackfillRequired => "backfill_required",
1505                SafetyClass::Destructive => "destructive",
1506                SafetyClass::Opaque => "opaque",
1507                SafetyClass::Unsupported => "unsupported",
1508            };
1509            output.push_str(name);
1510            match gate_safety(7, safety, false, false) {
1511                Ok(()) => output.push_str("|accepted\n"),
1512                Err(error) => {
1513                    assert_eq!(error.operation_index(), Some(7));
1514                    assert_eq!(error.safety(), Some(safety));
1515                    output.push('|');
1516                    output.push_str(error.code());
1517                    output.push('|');
1518                    output.push_str(error.message());
1519                    output.push('\n');
1520                }
1521            }
1522        }
1523        assert_eq!(
1524            output,
1525            include_str!("../tests/fixtures/lowering-safety-gate-v1.txt")
1526        );
1527    }
1528
1529    #[test]
1530    fn destructive_gate_opens_only_under_approval() {
1531        assert!(gate_safety(0, SafetyClass::Destructive, false, true).is_ok());
1532        // An approval never opens classes no approval can execute.
1533        assert!(gate_safety(0, SafetyClass::Opaque, false, true).is_err());
1534        assert!(gate_safety(0, SafetyClass::BackfillRequired, false, true).is_err());
1535        assert!(gate_safety(0, SafetyClass::Unsupported, false, true).is_err());
1536        assert!(gate_safety(0, SafetyClass::Conditional, false, true).is_err());
1537    }
1538}