Skip to main content

type_bridge_query/
query_validation.rs

1//! Schema-aware validation producing execution-ready query plans.
2//!
3//! Only Rust can produce a [`ValidatedQuery`]: it resolves the plan's
4//! bindings against one exact resolved schema and managed state, derives
5//! every binding's runtime domain, walks the stage pipeline to the output
6//! row schema, and refuses ambiguity instead of defaulting it.
7
8use std::collections::{BTreeMap, BTreeSet};
9
10use type_bridge_contract::diagnostic::{Diagnostic, DiagnosticCategory, DiagnosticCode};
11use type_bridge_contract::id::{TypeId, TypeKind, is_typeql_3_12_builtin_function_name};
12use type_bridge_contract::limits::StructuralLimits;
13use type_bridge_contract::migration_assertion::BindingId;
14use type_bridge_contract::query_plan::{
15    DocumentSource, LocalFunction, QueryOperand, QueryOutput, QueryPattern, QueryPatternV2,
16    QueryPlan, ReadStage, Reducer,
17};
18use type_bridge_contract::schema::{AnnotationKindId, SchemaAnnotationValue};
19use type_bridge_contract::schema_delta::ManagedSchemaState;
20use type_bridge_contract::value::{CanonicalValue, ValueTypeTag};
21
22use crate::engine::{self, EngineCode, EngineCodes};
23use crate::query_v2_claims::validate_v2_schema_claims;
24use crate::{
25    BindingDomain, DocumentColumn, DocumentColumnShape, DocumentSchema,
26    MigrationAssertionValidationContext, OutputSchema, RowColumn, RowSchema,
27};
28
29/// The stable diagnostic vocabulary of query-plan validation.
30const QUERY_ENGINE_CODES: EngineCodes = EngineCodes {
31    unknown_type: EngineCode {
32        code: "query_plan_unknown_type",
33        message: "isa pattern references a type outside the resolved schema",
34    },
35    unknown_attribute: EngineCode {
36        code: "query_plan_unknown_attribute",
37        message: "has pattern references an attribute outside the resolved schema",
38    },
39    unknown_relation: EngineCode {
40        code: "query_plan_unknown_relation",
41        message: "links pattern relation is absent or not relation-kind",
42    },
43    unknown_role: EngineCode {
44        code: "query_plan_unknown_role",
45        message: "links pattern references a role outside the resolved schema",
46    },
47    role_relation_mismatch: EngineCode {
48        code: "query_plan_role_relation_mismatch",
49        message: "links role is not effective on the declared relation",
50    },
51    root_reference_not_positive: EngineCode {
52        code: "query_plan_binding_not_positive",
53        message: "a root-scope reference is not positively established at the root",
54    },
55    negation_unbound: EngineCode {
56        code: "query_plan_negation_unbound_binding",
57        message: "negation-local reference is not positively established in its body",
58    },
59    empty_negated_domain: EngineCode {
60        code: "query_plan_empty_negated_domain",
61        message: "negated pattern has an impossible schema domain",
62    },
63    value_domain_mismatch: EngineCode {
64        code: "query_plan_value_domain_mismatch",
65        message: "value comparison operands have different scalar domains",
66    },
67    value_comparator_unsupported: EngineCode {
68        code: "query_plan_value_comparator_unsupported",
69        message: "ordered comparisons require a provider-orderable scalar domain",
70    },
71    binding_not_scalar: EngineCode {
72        code: "query_plan_binding_not_scalar",
73        message: "value operand binding has no uniform attribute scalar domain",
74    },
75    nonuniform_value_domain: EngineCode {
76        code: "query_plan_nonuniform_value_domain",
77        message: "binding domain mixes incompatible scalar domains",
78    },
79    disconnected_topology: EngineCode {
80        code: "query_plan_disconnected_topology",
81        message: "positive query bindings form a disconnected cross join",
82    },
83    unknown_input: EngineCode {
84        code: "query_plan_unknown_input_column",
85        message: "pattern references an undeclared input column",
86    },
87    unknown_function: EngineCode {
88        code: "query_plan_unknown_function",
89        message: "call references a function outside the resolved schema",
90    },
91    function_return_unsupported: EngineCode {
92        code: "query_plan_function_return_unsupported",
93        message: "the first function vocabulary admits scalar non-optional returns only",
94    },
95    function_arity_mismatch: EngineCode {
96        code: "query_plan_function_arity_mismatch",
97        message: "call arguments do not match the function signature arity",
98    },
99    function_argument_type: EngineCode {
100        code: "query_plan_function_argument_type",
101        message: "call argument disagrees with the declared parameter type",
102    },
103    function_dependency_cycle: EngineCode {
104        code: "query_plan_function_dependency_cycle",
105        message: "function-call result dependencies must be acyclic",
106    },
107    value_binding_misuse: EngineCode {
108        code: "query_plan_value_binding_misuse",
109        message: "a value binding may appear only as a comparison or argument operand",
110    },
111    try_unbound: EngineCode {
112        code: "query_plan_try_unbound_binding",
113        message: "try-body reference is not established in its body or the root",
114    },
115    try_uncorrelated: EngineCode {
116        code: "query_plan_try_not_correlated",
117        message: "a try body must reference at least one mandatory root binding",
118    },
119    empty_try_domain: EngineCode {
120        code: "query_plan_empty_try_domain",
121        message: "try body has an impossible schema domain",
122    },
123    try_binding_shared: EngineCode {
124        code: "query_plan_try_binding_shared",
125        message: "an optional binding belongs to exactly one try body and no negation scope",
126    },
127    local_unbound: EngineCode {
128        code: "query_plan_local_function_unbound",
129        message: "local-body reference is not a parameter or body-established",
130    },
131    local_uncorrelated: EngineCode {
132        code: "query_plan_local_function_uncorrelated",
133        message: "a local function body must reference every parameter",
134    },
135    empty_local_domain: EngineCode {
136        code: "query_plan_empty_local_function_domain",
137        message: "local function body has an impossible schema domain",
138    },
139    local_return_domain: EngineCode {
140        code: "query_plan_local_function_return_domain",
141        message: "the declared return does not fit the reducer over its input domain",
142    },
143};
144
145/// Opaque, non-serializable result of schema-aware plan validation.
146#[derive(Clone, Debug, Eq, PartialEq)]
147pub struct ValidatedQuery {
148    binding_domains: BTreeMap<BindingId, BindingDomain>,
149    output_schema: OutputSchema,
150    plan: QueryPlan,
151    root_visibility: Vec<BindingId>,
152    source_state: ManagedSchemaState,
153    structural_limits: StructuralLimits,
154}
155
156impl ValidatedQuery {
157    /// Return the context-free trusted plan.
158    pub const fn plan(&self) -> &QueryPlan {
159        &self.plan
160    }
161
162    /// Return one schema-derived binding domain.
163    pub fn binding_domain(&self, id: &BindingId) -> Option<&BindingDomain> {
164        self.binding_domains.get(id)
165    }
166
167    /// Return the validator-derived output shape.
168    pub const fn output_schema(&self) -> &OutputSchema {
169        &self.output_schema
170    }
171
172    /// Return the exact managed and declared schema identity validated here.
173    pub const fn source_state(&self) -> &ManagedSchemaState {
174        &self.source_state
175    }
176
177    /// Return the exact structural policy used during validation.
178    pub const fn structural_limits(&self) -> StructuralLimits {
179        self.structural_limits
180    }
181
182    /// Return the bindings positively visible in a root row, dense order.
183    ///
184    /// This is the validator-derived row environment after the pattern
185    /// conjunction and before any Select or Reduce stage: a binding
186    /// established only inside a negation is a witness, never a column.
187    /// Execution derives implicit projection from exactly this set, so a
188    /// plan without an explicit Select still requests only columns the
189    /// provider can produce.
190    pub fn root_visibility(&self) -> &[BindingId] {
191        &self.root_visibility
192    }
193}
194
195/// Return the compatibility-algebra edges that are guaranteed positive at the
196/// root, plus explicit V1 cross-join permissions.
197///
198/// The ordinary engine still owns the one topology check. Compatibility
199/// metadata contributes only edges with the released V1 meaning: conjunction
200/// unions edges, disjunction keeps their intersection, and negation exports
201/// none. This prevents an `or`-only or negated connection from laundering a
202/// disconnected product while allowing the production V1 bridge to keep its
203/// already-validated topology contract.
204fn v2_root_topology(plan: &QueryPlan) -> BTreeSet<(BindingId, BindingId)> {
205    let Some(compatibility) = plan.v2_compatibility() else {
206        return BTreeSet::new();
207    };
208    let mut edges = compatibility
209        .predicate()
210        .map(definite_v2_edges)
211        .unwrap_or_default();
212    edges.extend(
213        compatibility
214            .allowed_cross_joins()
215            .iter()
216            .map(|pair| canonical_topology_edge(pair.left(), pair.right())),
217    );
218    edges
219}
220
221fn definite_v2_edges(pattern: &QueryPatternV2) -> BTreeSet<(BindingId, BindingId)> {
222    match pattern {
223        QueryPatternV2::FieldValue { .. } => BTreeSet::new(),
224        QueryPatternV2::FieldComparison { left, right, .. } => {
225            BTreeSet::from([canonical_topology_edge(left.binding(), right.binding())])
226        }
227        QueryPatternV2::RoleEdge {
228            relation, player, ..
229        } => BTreeSet::from([canonical_topology_edge(*relation, *player)]),
230        QueryPatternV2::Reachable { source, target, .. } => {
231            BTreeSet::from([canonical_topology_edge(*source, *target)])
232        }
233        QueryPatternV2::And { patterns } => patterns
234            .iter()
235            .flat_map(definite_v2_edges)
236            .collect::<BTreeSet<_>>(),
237        QueryPatternV2::Or { patterns } => {
238            let mut patterns = patterns.iter();
239            let Some(first) = patterns.next() else {
240                return BTreeSet::new();
241            };
242            patterns.fold(definite_v2_edges(first), |common, pattern| {
243                common
244                    .intersection(&definite_v2_edges(pattern))
245                    .copied()
246                    .collect()
247            })
248        }
249        QueryPatternV2::Not { .. } => BTreeSet::new(),
250    }
251}
252
253const fn canonical_topology_edge(left: BindingId, right: BindingId) -> (BindingId, BindingId) {
254    if left.get() <= right.get() {
255        (left, right)
256    } else {
257        (right, left)
258    }
259}
260
261/// Validate one reusable plan against exact resolved schema authority.
262pub fn validate_query_plan(
263    plan: &QueryPlan,
264    context: &MigrationAssertionValidationContext<'_>,
265    limits: StructuralLimits,
266) -> Result<ValidatedQuery, Diagnostic> {
267    if plan.managed_semantics() != context.managed_state().managed_semantic_schema() {
268        return Err(plan_failure(
269            DiagnosticCategory::Integrity,
270            "query_plan_managed_semantic_mismatch",
271            "plan managed semantic fingerprint does not match validation state",
272        ));
273    }
274    if context.resolved_schema().declared_identity_fingerprint()
275        != context.managed_state().declared_identity()
276    {
277        return Err(plan_failure(
278            DiagnosticCategory::Integrity,
279            "query_plan_declared_identity_mismatch",
280            "resolved schema declaration identity does not match validation state",
281        ));
282    }
283    if plan.functions().iter().any(|function| {
284        is_typeql_3_12_builtin_function_name(function.name().label().as_str())
285            || patterns_contain_typeql_builtin_function(function.body())
286    }) || plan.pipeline().iter().any(|stage| match stage {
287        ReadStage::Match { patterns } => patterns_contain_typeql_builtin_function(patterns),
288        _ => false,
289    }) {
290        return Err(typeql_builtin_function_collision());
291    }
292    // The supplied limits are validation authority: the plan's entire
293    // structure — root pipeline and local functions under one aggregate
294    // predicate-node budget — re-checks under them, not a subset.
295    plan.check_structural_limits(limits)?;
296
297    let schema = context.resolved_schema();
298    // V2 model metadata is untrusted wire state. Recompute its provider-facing
299    // descriptor, closure, field, role, and player claims before ordinary
300    // engine analysis or any lowering can consume it.
301    let v2_claims = validate_v2_schema_claims(plan, schema)?;
302    let inputs = plan
303        .inputs()
304        .iter()
305        .map(|column| column.value_type())
306        .collect::<Vec<ValueTypeTag>>();
307    let Some(ReadStage::Match { patterns }) = plan.pipeline().first() else {
308        return Err(plan_failure(
309            DiagnosticCategory::InvalidContract,
310            "query_plan_match_not_first",
311            "the pattern conjunction must be the first pipeline stage",
312        ));
313    };
314    let mut locals = BTreeMap::new();
315    for function in plan.functions() {
316        if schema.functions().contains_key(function.name()) {
317            return Err(plan_failure(
318                DiagnosticCategory::InvalidContract,
319                "query_plan_local_function_shadows_schema",
320                "a plan-local function cannot shadow a schema function",
321            ));
322        }
323        let signature = engine::analyze_local_function(function, schema, &QUERY_ENGINE_CODES)?;
324        locals.insert(function.name().clone(), signature);
325    }
326
327    let engine::PatternAnalysis {
328        domains,
329        optional_positive,
330        positive,
331        scoped_positive,
332        used,
333        value_bindings,
334    } = engine::analyze_patterns(
335        patterns,
336        plan.bindings().len(),
337        &inputs,
338        schema,
339        &locals,
340        &v2_root_topology(plan),
341        &QUERY_ENGINE_CODES,
342    )?;
343
344    // Reduce assignments establish fresh value bindings outside the
345    // pattern conjunction; collect them before per-binding auditing.
346    let reduce_assigned: BTreeSet<BindingId> = plan
347        .pipeline()
348        .iter()
349        .filter_map(|stage| match stage {
350            ReadStage::Reduce { assignments, .. } => Some(assignments),
351            _ => None,
352        })
353        .flatten()
354        .map(|assignment| assignment.assigned())
355        .collect();
356
357    let projected: BTreeSet<BindingId> = match plan.output() {
358        QueryOutput::Rows { columns } => columns.iter().copied().collect(),
359        QueryOutput::Documents { fields } => fields
360            .iter()
361            .map(|field| match field.source() {
362                DocumentSource::Binding { binding } => *binding,
363                DocumentSource::AttributeList { owner, .. } => *owner,
364            })
365            .collect(),
366    };
367    for binding in plan.bindings() {
368        let id = binding.id();
369        if reduce_assigned.contains(&id) {
370            continue;
371        }
372        if !used.contains(&id) {
373            return Err(plan_failure(
374                DiagnosticCategory::InvalidContract,
375                "query_plan_binding_not_used",
376                "every declared binding must be referenced by a pattern",
377            ));
378        }
379        if projected.contains(&id) && !positive.contains(&id) && !optional_positive.contains(&id) {
380            return Err(plan_failure(
381                DiagnosticCategory::InvalidContract,
382                "query_plan_binding_not_positive",
383                "an output binding must be positively established at the root",
384            ));
385        }
386        if !projected.contains(&id)
387            && !positive.contains(&id)
388            && !optional_positive.contains(&id)
389            && !scoped_positive.contains(&id)
390        {
391            return Err(plan_failure(
392                DiagnosticCategory::InvalidContract,
393                "query_plan_invalid_witness",
394                "a hidden witness must be positively established in its lexical scope",
395            ));
396        }
397        if (positive.contains(&id) || optional_positive.contains(&id))
398            && domains[&id].is_empty()
399            && !value_bindings.contains_key(&id)
400            && !v2_claims.proves_empty_runtime_binding(id)
401        {
402            return Err(plan_failure(
403                DiagnosticCategory::InvalidContract,
404                "query_plan_empty_domain",
405                "schema validation reduced a binding to an empty runtime domain",
406            ));
407        }
408    }
409
410    let mut binding_domains = domains
411        .into_iter()
412        .filter(|(id, _)| positive.contains(id) || optional_positive.contains(id))
413        .map(|(id, type_ids)| {
414            let value_type = match value_bindings.get(&id) {
415                Some(tag) => Some(*tag),
416                None => engine::uniform_value_type(&type_ids, schema, &QUERY_ENGINE_CODES)?,
417            };
418            Ok((id, BindingDomain::new(type_ids, value_type)))
419        })
420        .collect::<Result<BTreeMap<_, _>, Diagnostic>>()?;
421
422    for stage in plan.pipeline() {
423        match stage {
424            ReadStage::Select { bindings } => {
425                for binding in bindings {
426                    if !positive.contains(binding) && !optional_positive.contains(binding) {
427                        return Err(plan_failure(
428                            DiagnosticCategory::InvalidContract,
429                            "query_plan_binding_not_positive",
430                            "stage bindings must be positively established at the root",
431                        ));
432                    }
433                }
434            }
435            ReadStage::Require { bindings } => {
436                for binding in bindings {
437                    if !positive.contains(binding) {
438                        return Err(plan_failure(
439                            DiagnosticCategory::InvalidContract,
440                            "query_plan_binding_not_positive",
441                            "stage bindings must be positively established at the root",
442                        ));
443                    }
444                }
445            }
446            ReadStage::Reduce {
447                assignments,
448                groups,
449            } => {
450                for group in groups {
451                    if !positive.contains(group) {
452                        return Err(plan_failure(
453                            DiagnosticCategory::InvalidContract,
454                            "query_plan_binding_not_positive",
455                            "stage bindings must be positively established at the root",
456                        ));
457                    }
458                }
459                for assignment in assignments {
460                    let input_scalar = match assignment.input() {
461                        Some(input) => {
462                            let admitted = positive.contains(&input)
463                                || (optional_positive.contains(&input)
464                                    && assignment.reducer().total_without_groups());
465                            if !admitted {
466                                return Err(plan_failure(
467                                    DiagnosticCategory::InvalidContract,
468                                    "query_plan_binding_not_positive",
469                                    "stage bindings must be positively established at the root",
470                                ));
471                            }
472                            binding_domains
473                                .get(&input)
474                                .and_then(|domain| domain.value_type())
475                        }
476                        None => None,
477                    };
478                    let result_type = match assignment.reducer() {
479                        Reducer::Count => ValueTypeTag::Long,
480                        Reducer::Sum | Reducer::Max | Reducer::Min => match input_scalar {
481                            Some(tag @ (ValueTypeTag::Long | ValueTypeTag::Double)) => tag,
482                            _ => {
483                                return Err(plan_failure(
484                                    DiagnosticCategory::InvalidContract,
485                                    "query_plan_reduce_input_domain",
486                                    "this reducer requires a uniform numeric scalar input",
487                                ));
488                            }
489                        },
490                        Reducer::Mean | Reducer::Median | Reducer::Std => match input_scalar {
491                            Some(ValueTypeTag::Long | ValueTypeTag::Double) => ValueTypeTag::Double,
492                            _ => {
493                                return Err(plan_failure(
494                                    DiagnosticCategory::InvalidContract,
495                                    "query_plan_reduce_input_domain",
496                                    "this reducer requires a uniform numeric scalar input",
497                                ));
498                            }
499                        },
500                    };
501                    binding_domains.insert(
502                        assignment.assigned(),
503                        BindingDomain::new(BTreeSet::new(), Some(result_type)),
504                    );
505                }
506            }
507            ReadStage::Sort { terms } => {
508                for term in terms {
509                    if !positive.contains(&term.binding())
510                        && !reduce_assigned.contains(&term.binding())
511                    {
512                        return Err(plan_failure(
513                            DiagnosticCategory::InvalidContract,
514                            "query_plan_stage_unknown_binding",
515                            "sort references a binding outside the mandatory row environment",
516                        ));
517                    }
518                    let scalar = binding_domains
519                        .get(&term.binding())
520                        .and_then(|domain| domain.value_type());
521                    let Some(scalar) = scalar else {
522                        return Err(plan_failure(
523                            DiagnosticCategory::InvalidContract,
524                            "query_plan_sort_not_scalar",
525                            "sort keys require a validated uniform scalar domain",
526                        ));
527                    };
528                    if !provider_sort_is_orderable(scalar) {
529                        return Err(plan_failure(
530                            DiagnosticCategory::InvalidContract,
531                            "query_plan_sort_not_orderable",
532                            "sort keys require a scalar domain ordered by the target provider",
533                        ));
534                    }
535                }
536            }
537            ReadStage::Match { .. }
538            | ReadStage::Distinct
539            | ReadStage::Offset { .. }
540            | ReadStage::Limit { .. } => {}
541        }
542    }
543
544    // A window consumes a total order: page membership must not depend on
545    // provider iteration among tied rows. Every binding visible at the
546    // window must be determined by the sort tuple. Besides identity-total
547    // sort keys, the proof admits owners identified by one unique attribute,
548    // deterministic plan-local function results over determined arguments,
549    // and reducer results once the complete group tuple is determined. A
550    // global reduce is already at most one row, so its order is vacuously
551    // total (the structural contract still requires an explicit Sort stage).
552    let windowed = plan
553        .pipeline()
554        .iter()
555        .any(|stage| matches!(stage, ReadStage::Offset { .. } | ReadStage::Limit { .. }));
556    if windowed {
557        let sort_keys: BTreeSet<BindingId> = plan
558            .pipeline()
559            .iter()
560            .filter_map(|stage| match stage {
561                ReadStage::Sort { terms } => Some(terms),
562                _ => None,
563            })
564            .flatten()
565            .map(|term| term.binding())
566            .collect();
567        let reduce = plan.pipeline().iter().find_map(|stage| match stage {
568            ReadStage::Reduce {
569                assignments,
570                groups,
571            } => Some((assignments.as_slice(), groups.as_slice())),
572            _ => None,
573        });
574        let window_environment: Vec<BindingId> = if let Some((assignments, groups)) = reduce {
575            groups
576                .iter()
577                .copied()
578                .chain(assignments.iter().map(|assignment| assignment.assigned()))
579                .collect()
580        } else if let Some(ReadStage::Select { bindings }) = plan
581            .pipeline()
582            .iter()
583            .find(|stage| matches!(stage, ReadStage::Select { .. }))
584        {
585            bindings.clone()
586        } else {
587            positive.union(&optional_positive).copied().collect()
588        };
589
590        let not_total = || {
591            plan_failure(
592                DiagnosticCategory::InvalidContract,
593                "query_plan_window_order_not_total",
594                "offset and limit require a sort tuple proven total for every visible column",
595            )
596        };
597        let global_reduce = reduce.is_some_and(|(_, groups)| groups.is_empty());
598        if !global_reduce {
599            let mut determined = BTreeSet::new();
600            for binding in &sort_keys {
601                let domain = binding_domains.get(binding).ok_or_else(&not_total)?;
602                if !sort_key_domain_is_identity_total(domain, schema) {
603                    return Err(not_total());
604                }
605                determined.insert(*binding);
606            }
607
608            loop {
609                let previous_len = determined.len();
610                for pattern in patterns {
611                    match pattern {
612                        QueryPattern::Has {
613                            owner,
614                            attribute,
615                            attribute_id,
616                        } if determined.contains(attribute) => {
617                            let owner_domain = binding_domains.get(owner).ok_or_else(&not_total)?;
618                            if !owner_domain.type_ids().is_empty()
619                                && binding_domains.get(attribute).is_some_and(|domain| {
620                                    sort_key_domain_is_identity_total(domain, schema)
621                                })
622                                && one_unique_owns_scope_covers_domain(
623                                    owner_domain.type_ids(),
624                                    attribute_id,
625                                    schema,
626                                )
627                            {
628                                determined.insert(*owner);
629                            }
630                        }
631                        QueryPattern::FunctionCall {
632                            arguments,
633                            assigned,
634                            function,
635                        } if locals.contains_key(function)
636                            && arguments.iter().all(|argument| match argument {
637                                QueryOperand::Binding { binding } => determined.contains(binding),
638                                QueryOperand::Literal { .. } | QueryOperand::Input { .. } => true,
639                            }) =>
640                        {
641                            // Plan-local functions are closed aggregate
642                            // programs and therefore deterministic for one
643                            // argument tuple. Schema functions intentionally
644                            // receive no such proof from their signature.
645                            determined.insert(*assigned);
646                        }
647                        _ => {}
648                    }
649                }
650                if let Some((assignments, groups)) = reduce
651                    && groups.iter().all(|group| determined.contains(group))
652                {
653                    determined.extend(assignments.iter().map(|assignment| assignment.assigned()));
654                }
655                if determined.len() == previous_len {
656                    break;
657                }
658            }
659
660            if window_environment
661                .iter()
662                .any(|binding| !determined.contains(binding))
663            {
664                return Err(not_total());
665            }
666        }
667    }
668
669    let output_schema = match plan.output() {
670        QueryOutput::Rows { columns } => OutputSchema::Rows(RowSchema::new(
671            columns
672                .iter()
673                .map(|id| {
674                    let binding = plan
675                        .bindings()
676                        .get(usize::from(id.get()))
677                        .expect("validated output binding exists");
678                    RowColumn::new(
679                        *id,
680                        binding_domains[id].clone(),
681                        binding.variable().clone(),
682                        optional_positive.contains(id),
683                    )
684                })
685                .collect::<Vec<_>>(),
686        )),
687        QueryOutput::Documents { fields } => {
688            let columns = fields
689                .iter()
690                .map(|field| {
691                    let shape = match field.source() {
692                        DocumentSource::Binding { binding } => {
693                            let Some(value_type) = binding_domains[binding].value_type() else {
694                                return Err(plan_failure(
695                                    DiagnosticCategory::InvalidContract,
696                                    "query_plan_document_field_not_scalar",
697                                    "document fields fetch uniform scalar bindings",
698                                ));
699                            };
700                            DocumentColumnShape::Scalar {
701                                value_type,
702                                optional: optional_positive.contains(binding),
703                            }
704                        }
705                        DocumentSource::AttributeList { attribute, owner } => {
706                            if !positive.contains(owner) {
707                                return Err(plan_failure(
708                                    DiagnosticCategory::InvalidContract,
709                                    "query_plan_output_not_visible",
710                                    "attribute lists require a mandatory owner binding",
711                                ));
712                            }
713                            let attribute_type = TypeId::new(
714                                TypeKind::Attribute,
715                                attribute.label().as_str().to_owned(),
716                            )?;
717                            let Some(element_type) = schema
718                                .types()
719                                .get(&attribute_type)
720                                .and_then(|resolved| resolved.value_type())
721                                .map(|value| value.value_type())
722                            else {
723                                return Err(plan_failure(
724                                    DiagnosticCategory::InvalidContract,
725                                    "query_plan_unknown_attribute",
726                                    "attribute list references no resolved scalar attribute",
727                                ));
728                            };
729                            let reachable = binding_domains[owner].type_ids().iter().any(|id| {
730                                schema
731                                    .types()
732                                    .get(id)
733                                    .is_some_and(|resolved| resolved.owns().contains_key(attribute))
734                            });
735                            if !reachable {
736                                return Err(plan_failure(
737                                    DiagnosticCategory::InvalidContract,
738                                    "query_plan_document_unreachable_attribute",
739                                    "no type in the owner domain owns the listed attribute",
740                                ));
741                            }
742                            DocumentColumnShape::List {
743                                attribute: attribute.clone(),
744                                element_type,
745                            }
746                        }
747                    };
748                    Ok(DocumentColumn::new(field.key().clone(), shape))
749                })
750                .collect::<Result<Vec<_>, Diagnostic>>()?;
751            OutputSchema::Documents(DocumentSchema::new(columns))
752        }
753    };
754
755    Ok(ValidatedQuery {
756        binding_domains,
757        output_schema,
758        plan: plan.clone(),
759        root_visibility: positive.union(&optional_positive).copied().collect(),
760        source_state: context.managed_state().clone(),
761        structural_limits: limits,
762    })
763}
764
765/// Validate one plan-local function against exact resolved schema authority.
766///
767/// Incremental authoring calls this before committing a local-function scope
768/// claim. The ordinary whole-plan validator repeats the same analysis at
769/// finalization; this seam exists only so a rejected function cannot corrupt
770/// a mutable builder that has no root match stage yet.
771pub fn validate_query_local_function(
772    function: &LocalFunction,
773    context: &MigrationAssertionValidationContext<'_>,
774) -> Result<(), Diagnostic> {
775    let schema = context.resolved_schema();
776    if is_typeql_3_12_builtin_function_name(function.name().label().as_str())
777        || patterns_contain_typeql_builtin_function(function.body())
778    {
779        return Err(typeql_builtin_function_collision());
780    }
781    if schema.functions().contains_key(function.name()) {
782        return Err(plan_failure(
783            DiagnosticCategory::InvalidContract,
784            "query_plan_local_function_shadows_schema",
785            "a plan-local function cannot shadow a schema function",
786        ));
787    }
788    engine::analyze_local_function(function, schema, &QUERY_ENGINE_CODES)?;
789    Ok(())
790}
791
792const fn provider_sort_is_orderable(value_type: ValueTypeTag) -> bool {
793    !matches!(value_type, ValueTypeTag::Duration)
794}
795
796fn sort_key_domain_is_identity_total(
797    domain: &BindingDomain,
798    schema: &type_bridge_schema::ResolvedSchema,
799) -> bool {
800    let Some(value_type) = domain.value_type() else {
801        return false;
802    };
803    // TypeDB compares attributes by scalar value, not by the complete typed
804    // identity. Signed double zero and datetime-tz values with different
805    // designators can remain distinct identities while comparing equal, so
806    // those domains never receive an injectivity proof here.
807    if !provider_comparison_equality_matches_canonical_identity(value_type) {
808        return false;
809    }
810    if domain.type_ids().len() <= 1 {
811        return true;
812    }
813
814    finite_attribute_value_domains_are_pairwise_disjoint(domain, value_type, schema)
815}
816
817const fn provider_comparison_equality_matches_canonical_identity(value_type: ValueTypeTag) -> bool {
818    matches!(
819        value_type,
820        ValueTypeTag::String
821            | ValueTypeTag::Long
822            | ValueTypeTag::Boolean
823            | ValueTypeTag::Date
824            | ValueTypeTag::DateTime
825            | ValueTypeTag::Decimal
826    )
827}
828
829/// Prove a polymorphic scalar domain has no cross-type provider comparison ties.
830///
831/// `@values` is an exhaustive restriction. For scalar domains whose canonical
832/// equality agrees with provider comparison equality, exact set disjointness
833/// therefore proves that two different attribute types cannot contribute tied
834/// identities. Missing or malformed resolved evidence fails closed.
835fn finite_attribute_value_domains_are_pairwise_disjoint(
836    domain: &BindingDomain,
837    value_type: ValueTypeTag,
838    schema: &type_bridge_schema::ResolvedSchema,
839) -> bool {
840    let mut seen = BTreeSet::<&CanonicalValue>::new();
841    for type_id in domain.type_ids() {
842        if type_id.kind() != TypeKind::Attribute {
843            return false;
844        }
845        let Some(resolved) = schema.types().get(type_id) else {
846            return false;
847        };
848        if !resolved.is_constructible() {
849            return false;
850        }
851        let Some(resolved_value) = resolved.value_type() else {
852            return false;
853        };
854        if resolved_value.value_type() != value_type {
855            return false;
856        }
857        let Some(SchemaAnnotationValue::Values(values)) =
858            resolved_value.annotations().get(&AnnotationKindId::Values)
859        else {
860            return false;
861        };
862        for value in values.iter() {
863            if value.value_type() != value_type || !seen.insert(value) {
864                return false;
865            }
866        }
867    }
868    true
869}
870
871/// Prove one attribute value identifies an owner across the complete domain.
872///
873/// TypeDB scopes `@unique` (and the uniqueness implied by `@key`) to the
874/// owner type that declared the owns fact and that type's descendants. Two
875/// unrelated owner types may each declare the same attribute unique while
876/// still owning the same value. Requiring one shared declaration origin keeps
877/// a sort key injective across the whole union instead of proving uniqueness
878/// independently for each member.
879fn one_unique_owns_scope_covers_domain(
880    domain: &BTreeSet<TypeId>,
881    attribute: &type_bridge_contract::id::AttributeId,
882    schema: &type_bridge_schema::ResolvedSchema,
883) -> bool {
884    let mut origin = None;
885    for type_id in domain {
886        let Some(owns) = schema
887            .types()
888            .get(type_id)
889            .and_then(|resolved| resolved.owns().get(attribute))
890        else {
891            return false;
892        };
893        if !owns.is_unique() {
894            return false;
895        }
896        match &origin {
897            Some(expected) if expected != owns.origin().declared() => return false,
898            Some(_) => {}
899            None => origin = Some(owns.origin().declared().clone()),
900        }
901    }
902    origin.is_some()
903}
904
905fn typeql_builtin_function_collision() -> Diagnostic {
906    plan_failure(
907        DiagnosticCategory::InvalidContract,
908        "query_plan_builtin_function_collision",
909        "TypeQL 3.12 built-in function names cannot identify schema calls or plan-local functions",
910    )
911}
912
913fn patterns_contain_typeql_builtin_function(patterns: &[QueryPattern]) -> bool {
914    patterns.iter().any(|pattern| match pattern {
915        QueryPattern::FunctionCall { function, .. } => {
916            is_typeql_3_12_builtin_function_name(function.label().as_str())
917        }
918        QueryPattern::Or { branches } => branches
919            .iter()
920            .any(|branch| patterns_contain_typeql_builtin_function(branch)),
921        QueryPattern::Not { patterns } | QueryPattern::Try { patterns } => {
922            patterns_contain_typeql_builtin_function(patterns)
923        }
924        QueryPattern::Isa { .. }
925        | QueryPattern::Has { .. }
926        | QueryPattern::Links { .. }
927        | QueryPattern::Value { .. }
928        | QueryPattern::Reachable { .. } => false,
929    })
930}
931
932fn plan_failure(
933    category: DiagnosticCategory,
934    code: &'static str,
935    message: &'static str,
936) -> Diagnostic {
937    Diagnostic::new(
938        category,
939        DiagnosticCode::new(code).expect("static query-plan diagnostic code"),
940        message,
941    )
942}
943
944#[cfg(test)]
945mod tests {
946    use type_bridge_contract::id::{FunctionId, RoleId, TypeId, TypeKind};
947
948    use super::*;
949
950    #[test]
951    fn builtin_function_collision_scan_descends_every_pattern_container() {
952        let assigned = BindingId::new(0).expect("binding");
953        let nested = vec![QueryPattern::Not {
954            patterns: vec![QueryPattern::Try {
955                patterns: vec![QueryPattern::FunctionCall {
956                    arguments: Vec::new(),
957                    assigned,
958                    function: FunctionId::new("abs").expect("contextual function ID"),
959                }],
960            }],
961        }];
962        assert!(patterns_contain_typeql_builtin_function(&nested));
963
964        let safe = vec![QueryPattern::FunctionCall {
965            arguments: Vec::new(),
966            assigned,
967            function: FunctionId::new("absolute").expect("function ID"),
968        }];
969        assert!(!patterns_contain_typeql_builtin_function(&safe));
970    }
971
972    #[test]
973    fn compatibility_topology_exports_only_definite_positive_edges() {
974        let binding = |value| BindingId::new(value).expect("binding");
975        let role_edge =
976            |relation, player, relation_label: &str, role: &str| QueryPatternV2::RoleEdge {
977                include_relation_subtypes: false,
978                player: binding(player),
979                relation: binding(relation),
980                relation_type: TypeId::new(TypeKind::Relation, relation_label).expect("relation"),
981                role: RoleId::new(relation_label, role).expect("role"),
982            };
983        let edge_01 = role_edge(0, 1, "first-link", "member");
984        let edge_12 = role_edge(1, 2, "second-link", "member");
985
986        let conjunction = QueryPatternV2::And {
987            patterns: vec![edge_01.clone(), edge_12.clone()],
988        };
989        assert_eq!(
990            definite_v2_edges(&conjunction),
991            BTreeSet::from([(binding(0), binding(1)), (binding(1), binding(2))]),
992        );
993
994        let disjunction = QueryPatternV2::Or {
995            patterns: vec![
996                conjunction,
997                QueryPatternV2::And {
998                    patterns: vec![edge_01.clone()],
999                },
1000            ],
1001        };
1002        assert_eq!(
1003            definite_v2_edges(&disjunction),
1004            BTreeSet::from([(binding(0), binding(1))]),
1005            "an edge absent from one branch is not a root topology proof",
1006        );
1007        assert!(
1008            definite_v2_edges(&QueryPatternV2::Not {
1009                pattern: Box::new(edge_12),
1010            })
1011            .is_empty(),
1012            "negated edges never connect the positive root graph",
1013        );
1014    }
1015}