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, CollectionMode, SchemaAnnotationValue};
19use type_bridge_contract::schema_delta::ManagedSchemaState;
20use type_bridge_contract::value::{CanonicalValue, Cardinality, 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    // attribute values an owner holds with exactly `[1,1]` cardinality,
551    // players of a `[1,1]` role in a determined relation, deterministic
552    // plan-local function results over determined arguments, and reducer
553    // results once the complete group tuple is determined. Only root-level
554    // conjuncts propagate: optional, negated or disjunctive patterns do not
555    // fix one value per row. A
556    // global reduce is already at most one row, so its order is vacuously
557    // total (the structural contract still requires an explicit Sort stage).
558    let windowed = plan
559        .pipeline()
560        .iter()
561        .any(|stage| matches!(stage, ReadStage::Offset { .. } | ReadStage::Limit { .. }));
562    if windowed {
563        let sort_keys: BTreeSet<BindingId> = plan
564            .pipeline()
565            .iter()
566            .filter_map(|stage| match stage {
567                ReadStage::Sort { terms } => Some(terms),
568                _ => None,
569            })
570            .flatten()
571            .map(|term| term.binding())
572            .collect();
573        let reduce = plan.pipeline().iter().find_map(|stage| match stage {
574            ReadStage::Reduce {
575                assignments,
576                groups,
577            } => Some((assignments.as_slice(), groups.as_slice())),
578            _ => None,
579        });
580        let window_environment: Vec<BindingId> = if let Some((assignments, groups)) = reduce {
581            groups
582                .iter()
583                .copied()
584                .chain(assignments.iter().map(|assignment| assignment.assigned()))
585                .collect()
586        } else if let Some(ReadStage::Select { bindings }) = plan
587            .pipeline()
588            .iter()
589            .find(|stage| matches!(stage, ReadStage::Select { .. }))
590        {
591            bindings.clone()
592        } else {
593            positive.union(&optional_positive).copied().collect()
594        };
595
596        let not_total = || {
597            plan_failure(
598                DiagnosticCategory::InvalidContract,
599                "query_plan_window_order_not_total",
600                "offset and limit require a sort tuple proven total for every visible column",
601            )
602        };
603        let global_reduce = reduce.is_some_and(|(_, groups)| groups.is_empty());
604        if !global_reduce {
605            let mut determined = BTreeSet::new();
606            // Only identity-total keys seed the proof. A key without that
607            // proof (a double, say) is still admitted when the rest of the
608            // tuple determines it: rows it cannot separate, such as signed
609            // zeros, are then separated by the keys that determine it.
610            for binding in &sort_keys {
611                let domain = binding_domains.get(binding).ok_or_else(&not_total)?;
612                if sort_key_domain_is_identity_total(domain, schema) {
613                    determined.insert(*binding);
614                }
615            }
616
617            loop {
618                let previous_len = determined.len();
619                for pattern in patterns {
620                    match pattern {
621                        QueryPattern::Has {
622                            owner,
623                            attribute,
624                            attribute_id,
625                        } if determined.contains(attribute) => {
626                            let owner_domain = binding_domains.get(owner).ok_or_else(&not_total)?;
627                            if !owner_domain.type_ids().is_empty()
628                                && binding_domains.get(attribute).is_some_and(|domain| {
629                                    sort_key_domain_is_identity_total(domain, schema)
630                                })
631                                && one_unique_owns_scope_covers_domain(
632                                    owner_domain.type_ids(),
633                                    attribute_id,
634                                    schema,
635                                )
636                            {
637                                determined.insert(*owner);
638                            }
639                        }
640                        QueryPattern::Has {
641                            owner,
642                            attribute,
643                            attribute_id,
644                        } if determined.contains(owner)
645                            && binding_domains.get(owner).is_some_and(|domain| {
646                                singleton_owns_covers_domain(
647                                    domain.type_ids(),
648                                    attribute_id,
649                                    schema,
650                                )
651                            }) =>
652                        {
653                            determined.insert(*attribute);
654                        }
655                        QueryPattern::Links {
656                            players, relation, ..
657                        } if determined.contains(relation) => {
658                            let relation_domain =
659                                binding_domains.get(relation).ok_or_else(&not_total)?;
660                            for player in players {
661                                if singleton_relates_covers_domain(
662                                    relation_domain.type_ids(),
663                                    player.role(),
664                                    schema,
665                                ) {
666                                    determined.insert(player.player());
667                                }
668                            }
669                        }
670                        QueryPattern::FunctionCall {
671                            arguments,
672                            assigned,
673                            function,
674                        } if locals.contains_key(function)
675                            && arguments.iter().all(|argument| match argument {
676                                QueryOperand::Binding { binding } => determined.contains(binding),
677                                QueryOperand::Literal { .. } | QueryOperand::Input { .. } => true,
678                            }) =>
679                        {
680                            // Plan-local functions are closed aggregate
681                            // programs and therefore deterministic for one
682                            // argument tuple. Schema functions intentionally
683                            // receive no such proof from their signature.
684                            determined.insert(*assigned);
685                        }
686                        _ => {}
687                    }
688                }
689                if let Some((assignments, groups)) = reduce
690                    && groups.iter().all(|group| determined.contains(group))
691                {
692                    determined.extend(assignments.iter().map(|assignment| assignment.assigned()));
693                }
694                if determined.len() == previous_len {
695                    break;
696                }
697            }
698
699            if window_environment
700                .iter()
701                .chain(&sort_keys)
702                .any(|binding| !determined.contains(binding))
703            {
704                return Err(not_total());
705            }
706        }
707    }
708
709    let output_schema = match plan.output() {
710        QueryOutput::Rows { columns } => OutputSchema::Rows(RowSchema::new(
711            columns
712                .iter()
713                .map(|id| {
714                    let binding = plan
715                        .bindings()
716                        .get(usize::from(id.get()))
717                        .expect("validated output binding exists");
718                    RowColumn::new(
719                        *id,
720                        binding_domains[id].clone(),
721                        binding.variable().clone(),
722                        optional_positive.contains(id),
723                    )
724                })
725                .collect::<Vec<_>>(),
726        )),
727        QueryOutput::Documents { fields } => {
728            let columns = fields
729                .iter()
730                .map(|field| {
731                    let shape = match field.source() {
732                        DocumentSource::Binding { binding } => {
733                            let Some(value_type) = binding_domains[binding].value_type() else {
734                                return Err(plan_failure(
735                                    DiagnosticCategory::InvalidContract,
736                                    "query_plan_document_field_not_scalar",
737                                    "document fields fetch uniform scalar bindings",
738                                ));
739                            };
740                            DocumentColumnShape::Scalar {
741                                value_type,
742                                optional: optional_positive.contains(binding),
743                            }
744                        }
745                        DocumentSource::AttributeList { attribute, owner } => {
746                            if !positive.contains(owner) {
747                                return Err(plan_failure(
748                                    DiagnosticCategory::InvalidContract,
749                                    "query_plan_output_not_visible",
750                                    "attribute lists require a mandatory owner binding",
751                                ));
752                            }
753                            let attribute_type = TypeId::new(
754                                TypeKind::Attribute,
755                                attribute.label().as_str().to_owned(),
756                            )?;
757                            let Some(element_type) = schema
758                                .types()
759                                .get(&attribute_type)
760                                .and_then(|resolved| resolved.value_type())
761                                .map(|value| value.value_type())
762                            else {
763                                return Err(plan_failure(
764                                    DiagnosticCategory::InvalidContract,
765                                    "query_plan_unknown_attribute",
766                                    "attribute list references no resolved scalar attribute",
767                                ));
768                            };
769                            let reachable = binding_domains[owner].type_ids().iter().any(|id| {
770                                schema
771                                    .types()
772                                    .get(id)
773                                    .is_some_and(|resolved| resolved.owns().contains_key(attribute))
774                            });
775                            if !reachable {
776                                return Err(plan_failure(
777                                    DiagnosticCategory::InvalidContract,
778                                    "query_plan_document_unreachable_attribute",
779                                    "no type in the owner domain owns the listed attribute",
780                                ));
781                            }
782                            DocumentColumnShape::List {
783                                attribute: attribute.clone(),
784                                element_type,
785                            }
786                        }
787                    };
788                    Ok(DocumentColumn::new(field.key().clone(), shape))
789                })
790                .collect::<Result<Vec<_>, Diagnostic>>()?;
791            OutputSchema::Documents(DocumentSchema::new(columns))
792        }
793    };
794
795    Ok(ValidatedQuery {
796        binding_domains,
797        output_schema,
798        plan: plan.clone(),
799        root_visibility: positive.union(&optional_positive).copied().collect(),
800        source_state: context.managed_state().clone(),
801        structural_limits: limits,
802    })
803}
804
805/// Validate one plan-local function against exact resolved schema authority.
806///
807/// Incremental authoring calls this before committing a local-function scope
808/// claim. The ordinary whole-plan validator repeats the same analysis at
809/// finalization; this seam exists only so a rejected function cannot corrupt
810/// a mutable builder that has no root match stage yet.
811pub fn validate_query_local_function(
812    function: &LocalFunction,
813    context: &MigrationAssertionValidationContext<'_>,
814) -> Result<(), Diagnostic> {
815    let schema = context.resolved_schema();
816    if is_typeql_3_12_builtin_function_name(function.name().label().as_str())
817        || patterns_contain_typeql_builtin_function(function.body())
818    {
819        return Err(typeql_builtin_function_collision());
820    }
821    if schema.functions().contains_key(function.name()) {
822        return Err(plan_failure(
823            DiagnosticCategory::InvalidContract,
824            "query_plan_local_function_shadows_schema",
825            "a plan-local function cannot shadow a schema function",
826        ));
827    }
828    engine::analyze_local_function(function, schema, &QUERY_ENGINE_CODES)?;
829    Ok(())
830}
831
832const fn provider_sort_is_orderable(value_type: ValueTypeTag) -> bool {
833    !matches!(value_type, ValueTypeTag::Duration)
834}
835
836fn sort_key_domain_is_identity_total(
837    domain: &BindingDomain,
838    schema: &type_bridge_schema::ResolvedSchema,
839) -> bool {
840    let Some(value_type) = domain.value_type() else {
841        return false;
842    };
843    // TypeDB compares attributes by scalar value, not by the complete typed
844    // identity. Signed double zero and datetime-tz values with different
845    // designators can remain distinct identities while comparing equal, so
846    // those domains never receive an injectivity proof here.
847    if !provider_comparison_equality_matches_canonical_identity(value_type) {
848        return false;
849    }
850    if domain.type_ids().len() <= 1 {
851        return true;
852    }
853
854    finite_attribute_value_domains_are_pairwise_disjoint(domain, value_type, schema)
855}
856
857const fn provider_comparison_equality_matches_canonical_identity(value_type: ValueTypeTag) -> bool {
858    matches!(
859        value_type,
860        ValueTypeTag::String
861            | ValueTypeTag::Long
862            | ValueTypeTag::Boolean
863            | ValueTypeTag::Date
864            | ValueTypeTag::DateTime
865            | ValueTypeTag::Decimal
866    )
867}
868
869/// Prove a polymorphic scalar domain has no cross-type provider comparison ties.
870///
871/// `@values` is an exhaustive restriction. For scalar domains whose canonical
872/// equality agrees with provider comparison equality, exact set disjointness
873/// therefore proves that two different attribute types cannot contribute tied
874/// identities. Missing or malformed resolved evidence fails closed.
875fn finite_attribute_value_domains_are_pairwise_disjoint(
876    domain: &BindingDomain,
877    value_type: ValueTypeTag,
878    schema: &type_bridge_schema::ResolvedSchema,
879) -> bool {
880    let mut seen = BTreeSet::<&CanonicalValue>::new();
881    for type_id in domain.type_ids() {
882        if type_id.kind() != TypeKind::Attribute {
883            return false;
884        }
885        let Some(resolved) = schema.types().get(type_id) else {
886            return false;
887        };
888        if !resolved.is_constructible() {
889            return false;
890        }
891        let Some(resolved_value) = resolved.value_type() else {
892            return false;
893        };
894        if resolved_value.value_type() != value_type {
895            return false;
896        }
897        let Some(SchemaAnnotationValue::Values(values)) =
898            resolved_value.annotations().get(&AnnotationKindId::Values)
899        else {
900            return false;
901        };
902        for value in values.iter() {
903            if value.value_type() != value_type || !seen.insert(value) {
904                return false;
905            }
906        }
907    }
908    true
909}
910
911/// Prove one attribute value identifies an owner across the complete domain.
912///
913/// TypeDB scopes `@unique` (and the uniqueness implied by `@key`) to the
914/// owner type that declared the owns fact and that type's descendants. Two
915/// unrelated owner types may each declare the same attribute unique while
916/// still owning the same value. Requiring one shared declaration origin keeps
917/// a sort key injective across the whole union instead of proving uniqueness
918/// independently for each member.
919fn one_unique_owns_scope_covers_domain(
920    domain: &BTreeSet<TypeId>,
921    attribute: &type_bridge_contract::id::AttributeId,
922    schema: &type_bridge_schema::ResolvedSchema,
923) -> bool {
924    let mut origin = None;
925    for type_id in domain {
926        let Some(owns) = schema
927            .types()
928            .get(type_id)
929            .and_then(|resolved| resolved.owns().get(attribute))
930        else {
931            return false;
932        };
933        if !owns.is_unique() {
934            return false;
935        }
936        match &origin {
937            Some(expected) if expected != owns.origin().declared() => return false,
938            Some(_) => {}
939            None => origin = Some(owns.origin().declared().clone()),
940        }
941    }
942    origin.is_some()
943}
944
945const fn is_exactly_one(cardinality: Cardinality) -> bool {
946    cardinality.min() == 1 && matches!(cardinality.max(), Some(1))
947}
948
949/// Prove every owner in the domain holds exactly one value of the attribute.
950///
951/// A `[1,1]` unordered owns fact, direct or inherited, makes the attribute
952/// value a function of its owner. An empty domain or any member lacking that
953/// exact evidence fails closed.
954fn singleton_owns_covers_domain(
955    domain: &BTreeSet<TypeId>,
956    attribute: &type_bridge_contract::id::AttributeId,
957    schema: &type_bridge_schema::ResolvedSchema,
958) -> bool {
959    !domain.is_empty()
960        && domain.iter().all(|type_id| {
961            schema
962                .types()
963                .get(type_id)
964                .and_then(|resolved| resolved.owns().get(attribute))
965                .is_some_and(|owns| {
966                    owns.collection_mode() == CollectionMode::Unordered
967                        && is_exactly_one(owns.cardinality())
968                })
969        })
970}
971
972/// Prove every relation in the domain has exactly one player in the role.
973///
974/// The role must be effective with `[1,1]` unordered cardinality on each
975/// relation type and not replaced there by a specializing role, whose
976/// players would also match the inherited role.
977fn singleton_relates_covers_domain(
978    domain: &BTreeSet<TypeId>,
979    role: &type_bridge_contract::id::RoleId,
980    schema: &type_bridge_schema::ResolvedSchema,
981) -> bool {
982    !domain.is_empty()
983        && domain.iter().all(|type_id| {
984            schema.types().get(type_id).is_some_and(|resolved| {
985                resolved.relates().get(role).is_some_and(|relates| {
986                    relates.collection_mode() == CollectionMode::Unordered
987                        && is_exactly_one(relates.cardinality())
988                }) && !resolved
989                    .relates()
990                    .values()
991                    .any(|relates| relates.replaced_roles().contains(role))
992            })
993        })
994}
995
996fn typeql_builtin_function_collision() -> Diagnostic {
997    plan_failure(
998        DiagnosticCategory::InvalidContract,
999        "query_plan_builtin_function_collision",
1000        "TypeQL 3.12 built-in function names cannot identify schema calls or plan-local functions",
1001    )
1002}
1003
1004fn patterns_contain_typeql_builtin_function(patterns: &[QueryPattern]) -> bool {
1005    patterns.iter().any(|pattern| match pattern {
1006        QueryPattern::FunctionCall { function, .. } => {
1007            is_typeql_3_12_builtin_function_name(function.label().as_str())
1008        }
1009        QueryPattern::Or { branches } => branches
1010            .iter()
1011            .any(|branch| patterns_contain_typeql_builtin_function(branch)),
1012        QueryPattern::Not { patterns } | QueryPattern::Try { patterns } => {
1013            patterns_contain_typeql_builtin_function(patterns)
1014        }
1015        QueryPattern::Isa { .. }
1016        | QueryPattern::Has { .. }
1017        | QueryPattern::Links { .. }
1018        | QueryPattern::Value { .. }
1019        | QueryPattern::Reachable { .. } => false,
1020    })
1021}
1022
1023fn plan_failure(
1024    category: DiagnosticCategory,
1025    code: &'static str,
1026    message: &'static str,
1027) -> Diagnostic {
1028    Diagnostic::new(
1029        category,
1030        DiagnosticCode::new(code).expect("static query-plan diagnostic code"),
1031        message,
1032    )
1033}
1034
1035#[cfg(test)]
1036mod tests {
1037    use type_bridge_contract::id::{FunctionId, RoleId, TypeId, TypeKind};
1038
1039    use super::*;
1040
1041    #[test]
1042    fn builtin_function_collision_scan_descends_every_pattern_container() {
1043        let assigned = BindingId::new(0).expect("binding");
1044        let nested = vec![QueryPattern::Not {
1045            patterns: vec![QueryPattern::Try {
1046                patterns: vec![QueryPattern::FunctionCall {
1047                    arguments: Vec::new(),
1048                    assigned,
1049                    function: FunctionId::new("abs").expect("contextual function ID"),
1050                }],
1051            }],
1052        }];
1053        assert!(patterns_contain_typeql_builtin_function(&nested));
1054
1055        let safe = vec![QueryPattern::FunctionCall {
1056            arguments: Vec::new(),
1057            assigned,
1058            function: FunctionId::new("absolute").expect("function ID"),
1059        }];
1060        assert!(!patterns_contain_typeql_builtin_function(&safe));
1061    }
1062
1063    #[test]
1064    fn compatibility_topology_exports_only_definite_positive_edges() {
1065        let binding = |value| BindingId::new(value).expect("binding");
1066        let role_edge =
1067            |relation, player, relation_label: &str, role: &str| QueryPatternV2::RoleEdge {
1068                include_relation_subtypes: false,
1069                player: binding(player),
1070                relation: binding(relation),
1071                relation_type: TypeId::new(TypeKind::Relation, relation_label).expect("relation"),
1072                role: RoleId::new(relation_label, role).expect("role"),
1073            };
1074        let edge_01 = role_edge(0, 1, "first-link", "member");
1075        let edge_12 = role_edge(1, 2, "second-link", "member");
1076
1077        let conjunction = QueryPatternV2::And {
1078            patterns: vec![edge_01.clone(), edge_12.clone()],
1079        };
1080        assert_eq!(
1081            definite_v2_edges(&conjunction),
1082            BTreeSet::from([(binding(0), binding(1)), (binding(1), binding(2))]),
1083        );
1084
1085        let disjunction = QueryPatternV2::Or {
1086            patterns: vec![
1087                conjunction,
1088                QueryPatternV2::And {
1089                    patterns: vec![edge_01.clone()],
1090                },
1091            ],
1092        };
1093        assert_eq!(
1094            definite_v2_edges(&disjunction),
1095            BTreeSet::from([(binding(0), binding(1))]),
1096            "an edge absent from one branch is not a root topology proof",
1097        );
1098        assert!(
1099            definite_v2_edges(&QueryPatternV2::Not {
1100                pattern: Box::new(edge_12),
1101            })
1102            .is_empty(),
1103            "negated edges never connect the positive root graph",
1104        );
1105    }
1106}