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 { .. }
224        | QueryPatternV2::FieldPresence { .. }
225        | QueryPatternV2::BindingIid { .. } => BTreeSet::new(),
226        QueryPatternV2::FieldComparison { left, right, .. } => {
227            BTreeSet::from([canonical_topology_edge(left.binding(), right.binding())])
228        }
229        QueryPatternV2::RoleEdge {
230            relation, player, ..
231        } => BTreeSet::from([canonical_topology_edge(*relation, *player)]),
232        QueryPatternV2::Reachable { source, target, .. } => {
233            BTreeSet::from([canonical_topology_edge(*source, *target)])
234        }
235        QueryPatternV2::And { patterns } => patterns
236            .iter()
237            .flat_map(definite_v2_edges)
238            .collect::<BTreeSet<_>>(),
239        QueryPatternV2::Or { patterns } => {
240            let mut patterns = patterns.iter();
241            let Some(first) = patterns.next() else {
242                return BTreeSet::new();
243            };
244            patterns.fold(definite_v2_edges(first), |common, pattern| {
245                common
246                    .intersection(&definite_v2_edges(pattern))
247                    .copied()
248                    .collect()
249            })
250        }
251        QueryPatternV2::Not { .. } => BTreeSet::new(),
252    }
253}
254
255const fn canonical_topology_edge(left: BindingId, right: BindingId) -> (BindingId, BindingId) {
256    if left.get() <= right.get() {
257        (left, right)
258    } else {
259        (right, left)
260    }
261}
262
263/// Validate one reusable plan against exact resolved schema authority.
264pub fn validate_query_plan(
265    plan: &QueryPlan,
266    context: &MigrationAssertionValidationContext<'_>,
267    limits: StructuralLimits,
268) -> Result<ValidatedQuery, Diagnostic> {
269    if plan.managed_semantics() != context.managed_state().managed_semantic_schema() {
270        return Err(plan_failure(
271            DiagnosticCategory::Integrity,
272            "query_plan_managed_semantic_mismatch",
273            "plan managed semantic fingerprint does not match validation state",
274        ));
275    }
276    if context.resolved_schema().declared_identity_fingerprint()
277        != context.managed_state().declared_identity()
278    {
279        return Err(plan_failure(
280            DiagnosticCategory::Integrity,
281            "query_plan_declared_identity_mismatch",
282            "resolved schema declaration identity does not match validation state",
283        ));
284    }
285    if plan.functions().iter().any(|function| {
286        is_typeql_3_12_builtin_function_name(function.name().label().as_str())
287            || patterns_contain_typeql_builtin_function(function.body())
288    }) || plan.pipeline().iter().any(|stage| match stage {
289        ReadStage::Match { patterns } => patterns_contain_typeql_builtin_function(patterns),
290        _ => false,
291    }) {
292        return Err(typeql_builtin_function_collision());
293    }
294    // The supplied limits are validation authority: the plan's entire
295    // structure — root pipeline and local functions under one aggregate
296    // predicate-node budget — re-checks under them, not a subset.
297    plan.check_structural_limits(limits)?;
298
299    let schema = context.resolved_schema();
300    // V2 model metadata is untrusted wire state. Recompute its provider-facing
301    // descriptor, closure, field, role, and player claims before ordinary
302    // engine analysis or any lowering can consume it.
303    let v2_claims = validate_v2_schema_claims(plan, schema)?;
304    let inputs = plan
305        .inputs()
306        .iter()
307        .map(|column| column.value_type())
308        .collect::<Vec<ValueTypeTag>>();
309    let Some(ReadStage::Match { patterns }) = plan.pipeline().first() else {
310        return Err(plan_failure(
311            DiagnosticCategory::InvalidContract,
312            "query_plan_match_not_first",
313            "the pattern conjunction must be the first pipeline stage",
314        ));
315    };
316    let mut locals = BTreeMap::new();
317    for function in plan.functions() {
318        if schema.functions().contains_key(function.name()) {
319            return Err(plan_failure(
320                DiagnosticCategory::InvalidContract,
321                "query_plan_local_function_shadows_schema",
322                "a plan-local function cannot shadow a schema function",
323            ));
324        }
325        let signature = engine::analyze_local_function(function, schema, &QUERY_ENGINE_CODES)?;
326        locals.insert(function.name().clone(), signature);
327    }
328
329    let engine::PatternAnalysis {
330        domains,
331        optional_positive,
332        positive,
333        scoped_positive,
334        used,
335        value_bindings,
336    } = engine::analyze_patterns(
337        patterns,
338        plan.bindings().len(),
339        &inputs,
340        schema,
341        &locals,
342        &v2_root_topology(plan),
343        &QUERY_ENGINE_CODES,
344    )?;
345
346    // Reduce assignments establish fresh value bindings outside the
347    // pattern conjunction; collect them before per-binding auditing.
348    let reduce_assigned: BTreeSet<BindingId> = plan
349        .pipeline()
350        .iter()
351        .filter_map(|stage| match stage {
352            ReadStage::Reduce { assignments, .. } => Some(assignments),
353            _ => None,
354        })
355        .flatten()
356        .map(|assignment| assignment.assigned())
357        .collect();
358
359    let projected: BTreeSet<BindingId> = match plan.output() {
360        QueryOutput::Rows { columns } => columns.iter().copied().collect(),
361        QueryOutput::Documents { fields } => fields
362            .iter()
363            .map(|field| match field.source() {
364                DocumentSource::Binding { binding } => *binding,
365                DocumentSource::AttributeList { owner, .. } => *owner,
366            })
367            .collect(),
368    };
369    for binding in plan.bindings() {
370        let id = binding.id();
371        if reduce_assigned.contains(&id) {
372            continue;
373        }
374        if !used.contains(&id) {
375            return Err(plan_failure(
376                DiagnosticCategory::InvalidContract,
377                "query_plan_binding_not_used",
378                "every declared binding must be referenced by a pattern",
379            ));
380        }
381        if projected.contains(&id) && !positive.contains(&id) && !optional_positive.contains(&id) {
382            return Err(plan_failure(
383                DiagnosticCategory::InvalidContract,
384                "query_plan_binding_not_positive",
385                "an output binding must be positively established at the root",
386            ));
387        }
388        if !projected.contains(&id)
389            && !positive.contains(&id)
390            && !optional_positive.contains(&id)
391            && !scoped_positive.contains(&id)
392        {
393            return Err(plan_failure(
394                DiagnosticCategory::InvalidContract,
395                "query_plan_invalid_witness",
396                "a hidden witness must be positively established in its lexical scope",
397            ));
398        }
399        if (positive.contains(&id) || optional_positive.contains(&id))
400            && domains[&id].is_empty()
401            && !value_bindings.contains_key(&id)
402            && !v2_claims.proves_empty_runtime_binding(id)
403        {
404            return Err(plan_failure(
405                DiagnosticCategory::InvalidContract,
406                "query_plan_empty_domain",
407                "schema validation reduced a binding to an empty runtime domain",
408            ));
409        }
410    }
411
412    let mut binding_domains = domains
413        .into_iter()
414        .filter(|(id, _)| positive.contains(id) || optional_positive.contains(id))
415        .map(|(id, type_ids)| {
416            let value_type = match value_bindings.get(&id) {
417                Some(tag) => Some(*tag),
418                None => engine::uniform_value_type(&type_ids, schema, &QUERY_ENGINE_CODES)?,
419            };
420            Ok((id, BindingDomain::new(type_ids, value_type)))
421        })
422        .collect::<Result<BTreeMap<_, _>, Diagnostic>>()?;
423
424    for stage in plan.pipeline() {
425        match stage {
426            ReadStage::Select { bindings } => {
427                for binding in bindings {
428                    if !positive.contains(binding) && !optional_positive.contains(binding) {
429                        return Err(plan_failure(
430                            DiagnosticCategory::InvalidContract,
431                            "query_plan_binding_not_positive",
432                            "stage bindings must be positively established at the root",
433                        ));
434                    }
435                }
436            }
437            ReadStage::Require { bindings } => {
438                for binding in bindings {
439                    if !positive.contains(binding) {
440                        return Err(plan_failure(
441                            DiagnosticCategory::InvalidContract,
442                            "query_plan_binding_not_positive",
443                            "stage bindings must be positively established at the root",
444                        ));
445                    }
446                }
447            }
448            ReadStage::Reduce {
449                assignments,
450                groups,
451            } => {
452                for group in groups {
453                    if !positive.contains(group) {
454                        return Err(plan_failure(
455                            DiagnosticCategory::InvalidContract,
456                            "query_plan_binding_not_positive",
457                            "stage bindings must be positively established at the root",
458                        ));
459                    }
460                }
461                for assignment in assignments {
462                    let input_scalar = match assignment.input() {
463                        Some(input) => {
464                            let admitted = positive.contains(&input)
465                                || (optional_positive.contains(&input)
466                                    && assignment.reducer().total_without_groups());
467                            if !admitted {
468                                return Err(plan_failure(
469                                    DiagnosticCategory::InvalidContract,
470                                    "query_plan_binding_not_positive",
471                                    "stage bindings must be positively established at the root",
472                                ));
473                            }
474                            binding_domains
475                                .get(&input)
476                                .and_then(|domain| domain.value_type())
477                        }
478                        None => None,
479                    };
480                    let result_type = match assignment.reducer() {
481                        Reducer::Count => ValueTypeTag::Long,
482                        Reducer::Sum | Reducer::Max | Reducer::Min => match input_scalar {
483                            Some(tag @ (ValueTypeTag::Long | ValueTypeTag::Double)) => tag,
484                            _ => {
485                                return Err(plan_failure(
486                                    DiagnosticCategory::InvalidContract,
487                                    "query_plan_reduce_input_domain",
488                                    "this reducer requires a uniform numeric scalar input",
489                                ));
490                            }
491                        },
492                        Reducer::Mean | Reducer::Median | Reducer::Std => match input_scalar {
493                            Some(ValueTypeTag::Long | ValueTypeTag::Double) => ValueTypeTag::Double,
494                            _ => {
495                                return Err(plan_failure(
496                                    DiagnosticCategory::InvalidContract,
497                                    "query_plan_reduce_input_domain",
498                                    "this reducer requires a uniform numeric scalar input",
499                                ));
500                            }
501                        },
502                    };
503                    binding_domains.insert(
504                        assignment.assigned(),
505                        BindingDomain::new(BTreeSet::new(), Some(result_type)),
506                    );
507                }
508            }
509            ReadStage::Sort { terms } => {
510                for term in terms {
511                    if !positive.contains(&term.binding())
512                        && !reduce_assigned.contains(&term.binding())
513                    {
514                        return Err(plan_failure(
515                            DiagnosticCategory::InvalidContract,
516                            "query_plan_stage_unknown_binding",
517                            "sort references a binding outside the mandatory row environment",
518                        ));
519                    }
520                    let scalar = binding_domains
521                        .get(&term.binding())
522                        .and_then(|domain| domain.value_type());
523                    let Some(scalar) = scalar else {
524                        return Err(plan_failure(
525                            DiagnosticCategory::InvalidContract,
526                            "query_plan_sort_not_scalar",
527                            "sort keys require a validated uniform scalar domain",
528                        ));
529                    };
530                    if !provider_sort_is_orderable(scalar) {
531                        return Err(plan_failure(
532                            DiagnosticCategory::InvalidContract,
533                            "query_plan_sort_not_orderable",
534                            "sort keys require a scalar domain ordered by the target provider",
535                        ));
536                    }
537                }
538            }
539            ReadStage::Match { .. }
540            | ReadStage::Distinct
541            | ReadStage::Offset { .. }
542            | ReadStage::Limit { .. } => {}
543        }
544    }
545
546    // A window consumes a total order: page membership must not depend on
547    // provider iteration among tied rows. Every binding visible at the
548    // window must be determined by the sort tuple. Besides identity-total
549    // sort keys, the proof admits owners identified by one unique attribute,
550    // deterministic plan-local function results over determined arguments,
551    // and reducer results once the complete group tuple is determined. A
552    // global reduce is already at most one row, so its order is vacuously
553    // total (the structural contract still requires an explicit Sort stage).
554    let windowed = plan
555        .pipeline()
556        .iter()
557        .any(|stage| matches!(stage, ReadStage::Offset { .. } | ReadStage::Limit { .. }));
558    if windowed {
559        let sort_keys: BTreeSet<BindingId> = plan
560            .pipeline()
561            .iter()
562            .filter_map(|stage| match stage {
563                ReadStage::Sort { terms } => Some(terms),
564                _ => None,
565            })
566            .flatten()
567            .map(|term| term.binding())
568            .collect();
569        let reduce = plan.pipeline().iter().find_map(|stage| match stage {
570            ReadStage::Reduce {
571                assignments,
572                groups,
573            } => Some((assignments.as_slice(), groups.as_slice())),
574            _ => None,
575        });
576        let window_environment: Vec<BindingId> = if let Some((assignments, groups)) = reduce {
577            groups
578                .iter()
579                .copied()
580                .chain(assignments.iter().map(|assignment| assignment.assigned()))
581                .collect()
582        } else if let Some(ReadStage::Select { bindings }) = plan
583            .pipeline()
584            .iter()
585            .find(|stage| matches!(stage, ReadStage::Select { .. }))
586        {
587            bindings.clone()
588        } else {
589            positive.union(&optional_positive).copied().collect()
590        };
591
592        let not_total = || {
593            plan_failure(
594                DiagnosticCategory::InvalidContract,
595                "query_plan_window_order_not_total",
596                "offset and limit require a sort tuple proven total for every visible column",
597            )
598        };
599        let global_reduce = reduce.is_some_and(|(_, groups)| groups.is_empty());
600        if !global_reduce {
601            let mut determined = BTreeSet::new();
602            for binding in &sort_keys {
603                let domain = binding_domains.get(binding).ok_or_else(&not_total)?;
604                if !sort_key_domain_is_identity_total(domain, schema) {
605                    return Err(not_total());
606                }
607                determined.insert(*binding);
608            }
609
610            loop {
611                let previous_len = determined.len();
612                for pattern in patterns {
613                    match pattern {
614                        QueryPattern::Has {
615                            owner,
616                            attribute,
617                            attribute_id,
618                        } if determined.contains(attribute) => {
619                            let owner_domain = binding_domains.get(owner).ok_or_else(&not_total)?;
620                            if !owner_domain.type_ids().is_empty()
621                                && binding_domains.get(attribute).is_some_and(|domain| {
622                                    sort_key_domain_is_identity_total(domain, schema)
623                                })
624                                && one_unique_owns_scope_covers_domain(
625                                    owner_domain.type_ids(),
626                                    attribute_id,
627                                    schema,
628                                )
629                            {
630                                determined.insert(*owner);
631                            }
632                        }
633                        QueryPattern::FunctionCall {
634                            arguments,
635                            assigned,
636                            function,
637                        } if locals.contains_key(function)
638                            && arguments.iter().all(|argument| match argument {
639                                QueryOperand::Binding { binding } => determined.contains(binding),
640                                QueryOperand::Literal { .. } | QueryOperand::Input { .. } => true,
641                            }) =>
642                        {
643                            // Plan-local functions are closed aggregate
644                            // programs and therefore deterministic for one
645                            // argument tuple. Schema functions intentionally
646                            // receive no such proof from their signature.
647                            determined.insert(*assigned);
648                        }
649                        _ => {}
650                    }
651                }
652                if let Some((assignments, groups)) = reduce
653                    && groups.iter().all(|group| determined.contains(group))
654                {
655                    determined.extend(assignments.iter().map(|assignment| assignment.assigned()));
656                }
657                if determined.len() == previous_len {
658                    break;
659                }
660            }
661
662            if window_environment
663                .iter()
664                .any(|binding| !determined.contains(binding))
665            {
666                return Err(not_total());
667            }
668        }
669    }
670
671    let output_schema = match plan.output() {
672        QueryOutput::Rows { columns } => OutputSchema::Rows(RowSchema::new(
673            columns
674                .iter()
675                .map(|id| {
676                    let binding = plan
677                        .bindings()
678                        .get(usize::from(id.get()))
679                        .expect("validated output binding exists");
680                    RowColumn::new(
681                        *id,
682                        binding_domains[id].clone(),
683                        binding.variable().clone(),
684                        optional_positive.contains(id),
685                    )
686                })
687                .collect::<Vec<_>>(),
688        )),
689        QueryOutput::Documents { fields } => {
690            let columns = fields
691                .iter()
692                .map(|field| {
693                    let shape = match field.source() {
694                        DocumentSource::Binding { binding } => {
695                            let Some(value_type) = binding_domains[binding].value_type() else {
696                                return Err(plan_failure(
697                                    DiagnosticCategory::InvalidContract,
698                                    "query_plan_document_field_not_scalar",
699                                    "document fields fetch uniform scalar bindings",
700                                ));
701                            };
702                            DocumentColumnShape::Scalar {
703                                value_type,
704                                optional: optional_positive.contains(binding),
705                            }
706                        }
707                        DocumentSource::AttributeList { attribute, owner } => {
708                            if !positive.contains(owner) {
709                                return Err(plan_failure(
710                                    DiagnosticCategory::InvalidContract,
711                                    "query_plan_output_not_visible",
712                                    "attribute lists require a mandatory owner binding",
713                                ));
714                            }
715                            let attribute_type = TypeId::new(
716                                TypeKind::Attribute,
717                                attribute.label().as_str().to_owned(),
718                            )?;
719                            let Some(element_type) = schema
720                                .types()
721                                .get(&attribute_type)
722                                .and_then(|resolved| resolved.value_type())
723                                .map(|value| value.value_type())
724                            else {
725                                return Err(plan_failure(
726                                    DiagnosticCategory::InvalidContract,
727                                    "query_plan_unknown_attribute",
728                                    "attribute list references no resolved scalar attribute",
729                                ));
730                            };
731                            let reachable = binding_domains[owner].type_ids().iter().any(|id| {
732                                schema
733                                    .types()
734                                    .get(id)
735                                    .is_some_and(|resolved| resolved.owns().contains_key(attribute))
736                            });
737                            if !reachable {
738                                return Err(plan_failure(
739                                    DiagnosticCategory::InvalidContract,
740                                    "query_plan_document_unreachable_attribute",
741                                    "no type in the owner domain owns the listed attribute",
742                                ));
743                            }
744                            DocumentColumnShape::List {
745                                attribute: attribute.clone(),
746                                element_type,
747                            }
748                        }
749                    };
750                    Ok(DocumentColumn::new(field.key().clone(), shape))
751                })
752                .collect::<Result<Vec<_>, Diagnostic>>()?;
753            OutputSchema::Documents(DocumentSchema::new(columns))
754        }
755    };
756
757    Ok(ValidatedQuery {
758        binding_domains,
759        output_schema,
760        plan: plan.clone(),
761        root_visibility: positive.union(&optional_positive).copied().collect(),
762        source_state: context.managed_state().clone(),
763        structural_limits: limits,
764    })
765}
766
767/// Validate one plan-local function against exact resolved schema authority.
768///
769/// Incremental authoring calls this before committing a local-function scope
770/// claim. The ordinary whole-plan validator repeats the same analysis at
771/// finalization; this seam exists only so a rejected function cannot corrupt
772/// a mutable builder that has no root match stage yet.
773pub fn validate_query_local_function(
774    function: &LocalFunction,
775    context: &MigrationAssertionValidationContext<'_>,
776) -> Result<(), Diagnostic> {
777    let schema = context.resolved_schema();
778    if is_typeql_3_12_builtin_function_name(function.name().label().as_str())
779        || patterns_contain_typeql_builtin_function(function.body())
780    {
781        return Err(typeql_builtin_function_collision());
782    }
783    if schema.functions().contains_key(function.name()) {
784        return Err(plan_failure(
785            DiagnosticCategory::InvalidContract,
786            "query_plan_local_function_shadows_schema",
787            "a plan-local function cannot shadow a schema function",
788        ));
789    }
790    engine::analyze_local_function(function, schema, &QUERY_ENGINE_CODES)?;
791    Ok(())
792}
793
794const fn provider_sort_is_orderable(value_type: ValueTypeTag) -> bool {
795    !matches!(value_type, ValueTypeTag::Duration)
796}
797
798fn sort_key_domain_is_identity_total(
799    domain: &BindingDomain,
800    schema: &type_bridge_schema::ResolvedSchema,
801) -> bool {
802    let Some(value_type) = domain.value_type() else {
803        return false;
804    };
805    // TypeDB compares attributes by scalar value, not by the complete typed
806    // identity. Signed double zero and datetime-tz values with different
807    // designators can remain distinct identities while comparing equal, so
808    // those domains never receive an injectivity proof here.
809    if !provider_comparison_equality_matches_canonical_identity(value_type) {
810        return false;
811    }
812    if domain.type_ids().len() <= 1 {
813        return true;
814    }
815
816    finite_attribute_value_domains_are_pairwise_disjoint(domain, value_type, schema)
817}
818
819const fn provider_comparison_equality_matches_canonical_identity(value_type: ValueTypeTag) -> bool {
820    matches!(
821        value_type,
822        ValueTypeTag::String
823            | ValueTypeTag::Long
824            | ValueTypeTag::Boolean
825            | ValueTypeTag::Date
826            | ValueTypeTag::DateTime
827            | ValueTypeTag::Decimal
828    )
829}
830
831/// Prove a polymorphic scalar domain has no cross-type provider comparison ties.
832///
833/// `@values` is an exhaustive restriction. For scalar domains whose canonical
834/// equality agrees with provider comparison equality, exact set disjointness
835/// therefore proves that two different attribute types cannot contribute tied
836/// identities. Missing or malformed resolved evidence fails closed.
837fn finite_attribute_value_domains_are_pairwise_disjoint(
838    domain: &BindingDomain,
839    value_type: ValueTypeTag,
840    schema: &type_bridge_schema::ResolvedSchema,
841) -> bool {
842    let mut seen = BTreeSet::<&CanonicalValue>::new();
843    for type_id in domain.type_ids() {
844        if type_id.kind() != TypeKind::Attribute {
845            return false;
846        }
847        let Some(resolved) = schema.types().get(type_id) else {
848            return false;
849        };
850        if !resolved.is_constructible() {
851            return false;
852        }
853        let Some(resolved_value) = resolved.value_type() else {
854            return false;
855        };
856        if resolved_value.value_type() != value_type {
857            return false;
858        }
859        let Some(SchemaAnnotationValue::Values(values)) =
860            resolved_value.annotations().get(&AnnotationKindId::Values)
861        else {
862            return false;
863        };
864        for value in values.iter() {
865            if value.value_type() != value_type || !seen.insert(value) {
866                return false;
867            }
868        }
869    }
870    true
871}
872
873/// Prove one attribute value identifies an owner across the complete domain.
874///
875/// TypeDB scopes `@unique` (and the uniqueness implied by `@key`) to the
876/// owner type that declared the owns fact and that type's descendants. Two
877/// unrelated owner types may each declare the same attribute unique while
878/// still owning the same value. Requiring one shared declaration origin keeps
879/// a sort key injective across the whole union instead of proving uniqueness
880/// independently for each member.
881fn one_unique_owns_scope_covers_domain(
882    domain: &BTreeSet<TypeId>,
883    attribute: &type_bridge_contract::id::AttributeId,
884    schema: &type_bridge_schema::ResolvedSchema,
885) -> bool {
886    let mut origin = None;
887    for type_id in domain {
888        let Some(owns) = schema
889            .types()
890            .get(type_id)
891            .and_then(|resolved| resolved.owns().get(attribute))
892        else {
893            return false;
894        };
895        if !owns.is_unique() {
896            return false;
897        }
898        match &origin {
899            Some(expected) if expected != owns.origin().declared() => return false,
900            Some(_) => {}
901            None => origin = Some(owns.origin().declared().clone()),
902        }
903    }
904    origin.is_some()
905}
906
907fn typeql_builtin_function_collision() -> Diagnostic {
908    plan_failure(
909        DiagnosticCategory::InvalidContract,
910        "query_plan_builtin_function_collision",
911        "TypeQL 3.12 built-in function names cannot identify schema calls or plan-local functions",
912    )
913}
914
915fn patterns_contain_typeql_builtin_function(patterns: &[QueryPattern]) -> bool {
916    patterns.iter().any(|pattern| match pattern {
917        QueryPattern::FunctionCall { function, .. } => {
918            is_typeql_3_12_builtin_function_name(function.label().as_str())
919        }
920        QueryPattern::Or { branches } => branches
921            .iter()
922            .any(|branch| patterns_contain_typeql_builtin_function(branch)),
923        QueryPattern::Not { patterns } | QueryPattern::Try { patterns } => {
924            patterns_contain_typeql_builtin_function(patterns)
925        }
926        QueryPattern::Isa { .. }
927        | QueryPattern::Has { .. }
928        | QueryPattern::Links { .. }
929        | QueryPattern::Value { .. }
930        | QueryPattern::Reachable { .. } => false,
931    })
932}
933
934fn plan_failure(
935    category: DiagnosticCategory,
936    code: &'static str,
937    message: &'static str,
938) -> Diagnostic {
939    Diagnostic::new(
940        category,
941        DiagnosticCode::new(code).expect("static query-plan diagnostic code"),
942        message,
943    )
944}
945
946#[cfg(test)]
947mod tests {
948    use type_bridge_contract::id::{FunctionId, RoleId, TypeId, TypeKind};
949
950    use super::*;
951
952    #[test]
953    fn builtin_function_collision_scan_descends_every_pattern_container() {
954        let assigned = BindingId::new(0).expect("binding");
955        let nested = vec![QueryPattern::Not {
956            patterns: vec![QueryPattern::Try {
957                patterns: vec![QueryPattern::FunctionCall {
958                    arguments: Vec::new(),
959                    assigned,
960                    function: FunctionId::new("abs").expect("contextual function ID"),
961                }],
962            }],
963        }];
964        assert!(patterns_contain_typeql_builtin_function(&nested));
965
966        let safe = vec![QueryPattern::FunctionCall {
967            arguments: Vec::new(),
968            assigned,
969            function: FunctionId::new("absolute").expect("function ID"),
970        }];
971        assert!(!patterns_contain_typeql_builtin_function(&safe));
972    }
973
974    #[test]
975    fn compatibility_topology_exports_only_definite_positive_edges() {
976        let binding = |value| BindingId::new(value).expect("binding");
977        let role_edge =
978            |relation, player, relation_label: &str, role: &str| QueryPatternV2::RoleEdge {
979                include_relation_subtypes: false,
980                player: binding(player),
981                relation: binding(relation),
982                relation_type: TypeId::new(TypeKind::Relation, relation_label).expect("relation"),
983                role: RoleId::new(relation_label, role).expect("role"),
984            };
985        let edge_01 = role_edge(0, 1, "first-link", "member");
986        let edge_12 = role_edge(1, 2, "second-link", "member");
987
988        let conjunction = QueryPatternV2::And {
989            patterns: vec![edge_01.clone(), edge_12.clone()],
990        };
991        assert_eq!(
992            definite_v2_edges(&conjunction),
993            BTreeSet::from([(binding(0), binding(1)), (binding(1), binding(2))]),
994        );
995
996        let disjunction = QueryPatternV2::Or {
997            patterns: vec![
998                conjunction,
999                QueryPatternV2::And {
1000                    patterns: vec![edge_01.clone()],
1001                },
1002            ],
1003        };
1004        assert_eq!(
1005            definite_v2_edges(&disjunction),
1006            BTreeSet::from([(binding(0), binding(1))]),
1007            "an edge absent from one branch is not a root topology proof",
1008        );
1009        assert!(
1010            definite_v2_edges(&QueryPatternV2::Not {
1011                pattern: Box::new(edge_12),
1012            })
1013            .is_empty(),
1014            "negated edges never connect the positive root graph",
1015        );
1016    }
1017}