Skip to main content

icydb_core/db/query/
admission.rs

1//! Module: db::query::admission
2//! Responsibility: shared read-admission vocabulary for query surfaces.
3//! Does not own: physical planning, executor runtime, or SQL/fluent lowering.
4//! Boundary: describes policy, proven bounds, and stable rejection diagnostics.
5
6use std::fmt::Write as _;
7use std::num::{NonZeroU32, NonZeroU64};
8use std::ops::Bound;
9
10use crate::{
11    db::{
12        access::IndexBranchSetOrderedSuffix,
13        query::plan::{
14            AccessPlanProjection, AccessPlannedQuery, GroupPlan, QueryMode, ResidualFilterShape,
15            ScalarPlan, project_access_plan,
16        },
17    },
18    value::Value,
19};
20use icydb_diagnostic_code::{
21    Diagnostic, DiagnosticCode, DiagnosticDetail, ErrorCode, ErrorOrigin, QueryReadAdmissionCode,
22};
23
24/// Query execution lane selected by the public or internal caller surface.
25#[derive(Clone, Copy, Debug, Eq, PartialEq)]
26pub enum QueryAdmissionLane {
27    /// Caller-facing bounded read path for generated canister query endpoints.
28    PublicRead,
29    /// Trusted/admin ad-hoc read path with explicit budgets supplied by the embedder.
30    AdminAdHoc,
31    /// EXPLAIN-only path that describes planning and admission without row execution.
32    DiagnosticExplain,
33    /// Test-only lane for local harnesses that need to bypass production policy.
34    DevTest,
35}
36
37impl QueryAdmissionLane {
38    /// Return a stable lowercase diagnostic label for this lane.
39    #[must_use]
40    pub const fn as_str(self) -> &'static str {
41        match self {
42            Self::PublicRead => "public_read",
43            Self::AdminAdHoc => "admin_ad_hoc",
44            Self::DiagnosticExplain => "diagnostic_explain",
45            Self::DevTest => "dev_test",
46        }
47    }
48
49    /// Return whether this lane may execute and return data rows.
50    #[must_use]
51    pub const fn executes_rows(self) -> bool {
52        !matches!(self, Self::DiagnosticExplain)
53    }
54}
55
56/// Quality of the bound carried into read admission.
57#[derive(Clone, Copy, Debug, Eq, PartialEq)]
58pub enum QueryBoundKind {
59    /// Exact count known before execution.
60    Exact,
61    /// Conservative upper bound proven before execution.
62    ConservativeUpperBound,
63    /// Runtime cap enforced by the executor while producing rows.
64    EnforcedRuntimeCap,
65    /// Planner estimate only; not safe as public admission authority.
66    EstimateOnly,
67    /// No bound is available.
68    Unavailable,
69}
70
71impl QueryBoundKind {
72    /// Return a stable lowercase diagnostic label for this bound quality.
73    #[must_use]
74    pub const fn as_str(self) -> &'static str {
75        match self {
76            Self::Exact => "exact",
77            Self::ConservativeUpperBound => "conservative_upper_bound",
78            Self::EnforcedRuntimeCap => "enforced_runtime_cap",
79            Self::EstimateOnly => "estimate_only",
80            Self::Unavailable => "unavailable",
81        }
82    }
83
84    /// Return whether this bound kind is acceptable proof for public reads.
85    #[must_use]
86    pub const fn admits_public_read(self) -> bool {
87        matches!(
88            self,
89            Self::Exact | Self::ConservativeUpperBound | Self::EnforcedRuntimeCap
90        )
91    }
92}
93
94/// Final read-admission decision.
95#[derive(Clone, Copy, Debug, Eq, PartialEq)]
96pub enum QueryAdmissionDecision {
97    /// The selected plan is allowed under the lane policy.
98    Admitted,
99    /// The selected plan is rejected before execution.
100    Rejected,
101}
102
103impl QueryAdmissionDecision {
104    /// Return a stable lowercase diagnostic label for this decision.
105    #[must_use]
106    pub const fn as_str(self) -> &'static str {
107        match self {
108            Self::Admitted => "admitted",
109            Self::Rejected => "rejected",
110        }
111    }
112
113    /// Return whether the selected plan may execute.
114    #[must_use]
115    pub const fn is_admitted(self) -> bool {
116        matches!(self, Self::Admitted)
117    }
118}
119
120/// Coarse selected access-path class used by admission and EXPLAIN.
121#[derive(Clone, Copy, Debug, Eq, PartialEq)]
122pub enum QueryAdmissionAccessKind {
123    /// Access class has not been summarized yet.
124    Unknown,
125    /// Direct primary-key lookup.
126    ByKey,
127    /// Multiple direct primary-key lookups.
128    ByKeys,
129    /// Primary-key range access.
130    KeyRange,
131    /// Secondary-index prefix access.
132    IndexPrefix,
133    /// Secondary-index multi-lookup access.
134    IndexMultiLookup,
135    /// Secondary-index branch-set access.
136    IndexBranchSet,
137    /// Secondary-index range access.
138    IndexRange,
139    /// Full entity scan.
140    FullScan,
141    /// Union of multiple access paths.
142    Union,
143    /// Intersection of multiple access paths.
144    Intersection,
145}
146
147impl QueryAdmissionAccessKind {
148    /// Return a stable lowercase diagnostic label for this access class.
149    #[must_use]
150    pub const fn as_str(self) -> &'static str {
151        match self {
152            Self::Unknown => "unknown",
153            Self::ByKey => "by_key",
154            Self::ByKeys => "by_keys",
155            Self::KeyRange => "key_range",
156            Self::IndexPrefix => "index_prefix",
157            Self::IndexMultiLookup => "index_multi_lookup",
158            Self::IndexBranchSet => "index_branch_set",
159            Self::IndexRange => "index_range",
160            Self::FullScan => "full_scan",
161            Self::Union => "union",
162            Self::Intersection => "intersection",
163        }
164    }
165
166    /// Return whether this access class is backed by a secondary index.
167    #[must_use]
168    pub const fn is_secondary_index(self) -> bool {
169        matches!(
170            self,
171            Self::IndexPrefix | Self::IndexMultiLookup | Self::IndexBranchSet | Self::IndexRange
172        )
173    }
174
175    /// Return whether this access class is a full entity scan.
176    #[must_use]
177    pub const fn is_full_scan(self) -> bool {
178        matches!(self, Self::FullScan)
179    }
180}
181
182/// Coarse scalar/grouped statement shape used by read admission.
183#[derive(Clone, Copy, Debug, Eq, PartialEq)]
184pub enum QueryAdmissionPlanShape {
185    /// Scalar read shape, including projection-only and global-aggregate scalar plans.
186    ScalarRead,
187    /// Grouped aggregate read shape.
188    GroupedAggregate,
189    /// Delete shape surfaced only for diagnostics; public read lanes must not execute it.
190    Delete,
191}
192
193impl QueryAdmissionPlanShape {
194    /// Return a stable lowercase diagnostic label for this plan shape.
195    #[must_use]
196    pub const fn as_str(self) -> &'static str {
197        match self {
198            Self::ScalarRead => "scalar_read",
199            Self::GroupedAggregate => "grouped_aggregate",
200            Self::Delete => "delete",
201        }
202    }
203}
204
205/// Post-access residual filter shape relevant to admission.
206#[derive(Clone, Copy, Debug, Eq, PartialEq)]
207pub enum QueryAdmissionResidualFilter {
208    /// No residual runtime filter remains after access planning.
209    Absent,
210    /// A predicate-native residual filter remains.
211    Predicate,
212    /// An expression-backed residual filter remains.
213    Expression,
214    /// Both expression and predicate residual forms remain available.
215    ExpressionAndPredicate,
216}
217
218impl QueryAdmissionResidualFilter {
219    /// Return a stable lowercase diagnostic label for this residual shape.
220    #[must_use]
221    pub const fn as_str(self) -> &'static str {
222        match self {
223            Self::Absent => "none",
224            Self::Predicate => "predicate",
225            Self::Expression => "expression",
226            Self::ExpressionAndPredicate => "expression_and_predicate",
227        }
228    }
229
230    /// Return whether no residual runtime filter remains.
231    #[must_use]
232    pub const fn is_absent(self) -> bool {
233        matches!(self, Self::Absent)
234    }
235}
236
237/// ORDER BY facts relevant to read admission.
238#[derive(Clone, Copy, Debug, Eq, PartialEq)]
239pub enum QueryAdmissionOrdering {
240    /// No caller-visible ordering is requested.
241    None,
242    /// Ordering is requested but not yet resolved into executor slots.
243    Requested,
244    /// Ordering has a planner-resolved executor contract.
245    Resolved,
246}
247
248impl QueryAdmissionOrdering {
249    /// Return a stable lowercase diagnostic label for this ordering state.
250    #[must_use]
251    pub const fn as_str(self) -> &'static str {
252        match self {
253            Self::None => "none",
254            Self::Requested => "requested",
255            Self::Resolved => "resolved",
256        }
257    }
258}
259
260/// Grouped query facts relevant to read admission.
261#[derive(Clone, Copy, Debug, Eq, PartialEq)]
262pub struct QueryAdmissionGroupedSummary {
263    group_field_count: u32,
264    aggregate_count: u32,
265    max_groups: u64,
266    max_group_bytes: u64,
267    having_filter: bool,
268}
269
270impl QueryAdmissionGroupedSummary {
271    /// Build one grouped admission summary from planner-owned grouped facts.
272    #[must_use]
273    pub const fn new(
274        group_field_count: u32,
275        aggregate_count: u32,
276        max_groups: u64,
277        max_group_bytes: u64,
278        having_filter: bool,
279    ) -> Self {
280        Self {
281            group_field_count,
282            aggregate_count,
283            max_groups,
284            max_group_bytes,
285            having_filter,
286        }
287    }
288
289    /// Return the number of GROUP BY fields.
290    #[must_use]
291    pub const fn group_field_count(self) -> u32 {
292        self.group_field_count
293    }
294
295    /// Return the number of aggregate expressions.
296    #[must_use]
297    pub const fn aggregate_count(self) -> u32 {
298        self.aggregate_count
299    }
300
301    /// Return the grouped execution maximum group count.
302    #[must_use]
303    pub const fn max_groups(self) -> u64 {
304        self.max_groups
305    }
306
307    /// Return the grouped execution maximum bytes per group accumulator.
308    #[must_use]
309    pub const fn max_group_bytes(self) -> u64 {
310        self.max_group_bytes
311    }
312
313    /// Return whether the grouped plan has a HAVING residual expression.
314    #[must_use]
315    pub const fn has_having_filter(self) -> bool {
316        self.having_filter
317    }
318}
319
320/// Grouped/aggregate read admission budgets.
321#[derive(Clone, Copy, Debug, Eq, PartialEq)]
322pub struct GroupedAdmissionPolicy {
323    groups: Option<NonZeroU32>,
324    group_bytes: Option<NonZeroU32>,
325    distinct_entries: Option<NonZeroU32>,
326}
327
328impl GroupedAdmissionPolicy {
329    /// Build a policy that rejects grouped reads unless a later slice enables them.
330    #[must_use]
331    pub const fn disabled() -> Self {
332        Self {
333            groups: None,
334            group_bytes: None,
335            distinct_entries: None,
336        }
337    }
338
339    /// Build a grouped policy with explicit group and memory budgets.
340    #[must_use]
341    pub const fn bounded(
342        max_groups: NonZeroU32,
343        max_group_bytes: NonZeroU32,
344        max_distinct_entries: Option<NonZeroU32>,
345    ) -> Self {
346        Self {
347            groups: Some(max_groups),
348            group_bytes: Some(max_group_bytes),
349            distinct_entries: max_distinct_entries,
350        }
351    }
352
353    /// Return the maximum allowed output groups.
354    #[must_use]
355    pub const fn max_groups(&self) -> Option<NonZeroU32> {
356        self.groups
357    }
358
359    /// Return the maximum allowed bytes per group accumulator.
360    #[must_use]
361    pub const fn max_group_bytes(&self) -> Option<NonZeroU32> {
362        self.group_bytes
363    }
364
365    /// Return the maximum allowed distinct entries for distinct-style aggregates.
366    #[must_use]
367    pub const fn max_distinct_entries(&self) -> Option<NonZeroU32> {
368        self.distinct_entries
369    }
370
371    /// Return whether grouped execution has the minimum hard budgets admission needs.
372    #[must_use]
373    pub const fn has_hard_limits(&self) -> bool {
374        self.groups.is_some() && self.group_bytes.is_some()
375    }
376}
377
378#[derive(Clone, Copy, Debug, Eq, PartialEq)]
379enum LimitRequirement {
380    Required,
381    Optional,
382}
383
384#[derive(Clone, Copy, Debug, Eq, PartialEq)]
385enum IndexRequirement {
386    Required,
387    Optional,
388}
389
390#[derive(Clone, Copy, Debug, Eq, PartialEq)]
391enum FullScanPolicy {
392    Allow,
393    Reject,
394}
395
396#[derive(Clone, Copy, Debug, Eq, PartialEq)]
397enum MaterializedSortPolicy {
398    Allow,
399    Reject,
400}
401
402#[derive(Clone, Copy, Debug, Eq, PartialEq)]
403enum OffsetPolicy {
404    Allow,
405    RejectNonZero,
406}
407
408/// Read-admission policy attached to one query surface.
409#[derive(Clone, Debug, Eq, PartialEq)]
410pub struct QueryAdmissionPolicy {
411    lane: QueryAdmissionLane,
412    limit_requirement: LimitRequirement,
413    max_returned_rows: Option<NonZeroU32>,
414    max_scanned_rows: Option<NonZeroU64>,
415    max_response_bytes: Option<NonZeroU32>,
416    index_requirement: IndexRequirement,
417    offset_policy: OffsetPolicy,
418    full_scan_policy: FullScanPolicy,
419    materialized_sort_policy: MaterializedSortPolicy,
420    max_materialized_rows: Option<NonZeroU32>,
421    max_projection_columns: Option<NonZeroU32>,
422    grouped: GroupedAdmissionPolicy,
423}
424
425impl QueryAdmissionPolicy {
426    /// Build the safe default policy for caller-facing bounded read endpoints.
427    #[must_use]
428    pub const fn public_read(
429        max_returned_rows: NonZeroU32,
430        max_response_bytes: NonZeroU32,
431    ) -> Self {
432        Self {
433            lane: QueryAdmissionLane::PublicRead,
434            limit_requirement: LimitRequirement::Required,
435            max_returned_rows: Some(max_returned_rows),
436            max_scanned_rows: None,
437            max_response_bytes: Some(max_response_bytes),
438            index_requirement: IndexRequirement::Required,
439            offset_policy: OffsetPolicy::RejectNonZero,
440            full_scan_policy: FullScanPolicy::Reject,
441            materialized_sort_policy: MaterializedSortPolicy::Reject,
442            max_materialized_rows: None,
443            max_projection_columns: None,
444            grouped: GroupedAdmissionPolicy::disabled(),
445        }
446    }
447
448    /// Build a trusted ad-hoc policy with explicit execution budgets.
449    #[must_use]
450    pub const fn admin_ad_hoc(
451        max_returned_rows: NonZeroU32,
452        max_scanned_rows: NonZeroU64,
453        max_response_bytes: NonZeroU32,
454    ) -> Self {
455        Self {
456            lane: QueryAdmissionLane::AdminAdHoc,
457            limit_requirement: LimitRequirement::Optional,
458            max_returned_rows: Some(max_returned_rows),
459            max_scanned_rows: Some(max_scanned_rows),
460            max_response_bytes: Some(max_response_bytes),
461            index_requirement: IndexRequirement::Optional,
462            offset_policy: OffsetPolicy::Allow,
463            full_scan_policy: FullScanPolicy::Allow,
464            materialized_sort_policy: MaterializedSortPolicy::Allow,
465            max_materialized_rows: Some(max_returned_rows),
466            max_projection_columns: None,
467            grouped: GroupedAdmissionPolicy::disabled(),
468        }
469    }
470
471    /// Build an EXPLAIN-only policy that cannot execute rows.
472    #[must_use]
473    pub const fn diagnostic_explain() -> Self {
474        Self {
475            lane: QueryAdmissionLane::DiagnosticExplain,
476            limit_requirement: LimitRequirement::Optional,
477            max_returned_rows: None,
478            max_scanned_rows: None,
479            max_response_bytes: None,
480            index_requirement: IndexRequirement::Optional,
481            offset_policy: OffsetPolicy::Allow,
482            full_scan_policy: FullScanPolicy::Allow,
483            materialized_sort_policy: MaterializedSortPolicy::Allow,
484            max_materialized_rows: None,
485            max_projection_columns: None,
486            grouped: GroupedAdmissionPolicy::disabled(),
487        }
488    }
489
490    /// Build an unbounded test policy for local harnesses only.
491    #[must_use]
492    pub const fn dev_test_unbounded() -> Self {
493        Self {
494            lane: QueryAdmissionLane::DevTest,
495            limit_requirement: LimitRequirement::Optional,
496            max_returned_rows: None,
497            max_scanned_rows: None,
498            max_response_bytes: None,
499            index_requirement: IndexRequirement::Optional,
500            offset_policy: OffsetPolicy::Allow,
501            full_scan_policy: FullScanPolicy::Allow,
502            materialized_sort_policy: MaterializedSortPolicy::Allow,
503            max_materialized_rows: None,
504            max_projection_columns: None,
505            grouped: GroupedAdmissionPolicy::disabled(),
506        }
507    }
508
509    /// Return the lane this policy governs.
510    #[must_use]
511    pub const fn lane(&self) -> QueryAdmissionLane {
512        self.lane
513    }
514
515    /// Return whether the surface requires caller-visible LIMIT.
516    #[must_use]
517    pub const fn require_limit(&self) -> bool {
518        matches!(self.limit_requirement, LimitRequirement::Required)
519    }
520
521    /// Return the maximum rows that may be returned.
522    #[must_use]
523    pub const fn max_returned_rows(&self) -> Option<NonZeroU32> {
524        self.max_returned_rows
525    }
526
527    /// Return the maximum rows that may be scanned.
528    #[must_use]
529    pub const fn max_scanned_rows(&self) -> Option<NonZeroU64> {
530        self.max_scanned_rows
531    }
532
533    /// Return the maximum response bytes.
534    #[must_use]
535    pub const fn max_response_bytes(&self) -> Option<NonZeroU32> {
536        self.max_response_bytes
537    }
538
539    /// Return whether the selected plan must use an index-backed path.
540    #[must_use]
541    pub const fn require_index(&self) -> bool {
542        matches!(self.index_requirement, IndexRequirement::Required)
543    }
544
545    /// Return whether this surface rejects non-zero OFFSET execution.
546    #[must_use]
547    pub const fn reject_non_zero_offset(&self) -> bool {
548        matches!(self.offset_policy, OffsetPolicy::RejectNonZero)
549    }
550
551    /// Return whether a full entity scan may execute.
552    #[must_use]
553    pub const fn allow_full_scan(&self) -> bool {
554        matches!(self.full_scan_policy, FullScanPolicy::Allow)
555    }
556
557    /// Return whether this surface permits materialized ORDER BY execution.
558    #[must_use]
559    pub const fn allow_materialized_sort(&self) -> bool {
560        matches!(self.materialized_sort_policy, MaterializedSortPolicy::Allow)
561    }
562
563    /// Return the maximum rows that may be materialized for sort/projection work.
564    #[must_use]
565    pub const fn max_materialized_rows(&self) -> Option<NonZeroU32> {
566        self.max_materialized_rows
567    }
568
569    /// Return the maximum projected columns.
570    #[must_use]
571    pub const fn max_projection_columns(&self) -> Option<NonZeroU32> {
572        self.max_projection_columns
573    }
574
575    /// Return grouped/aggregate budgets.
576    #[must_use]
577    pub const fn grouped(&self) -> GroupedAdmissionPolicy {
578        self.grouped
579    }
580
581    /// Return whether public-read construction kept the mandatory finite caps.
582    #[must_use]
583    pub const fn public_caps_are_finite(&self) -> bool {
584        !matches!(self.lane, QueryAdmissionLane::PublicRead)
585            || (self.max_returned_rows.is_some() && self.max_response_bytes.is_some())
586    }
587
588    /// Apply this policy to one already-summarized plan.
589    #[must_use]
590    pub fn evaluate(&self, mut summary: QueryAdmissionSummary) -> QueryAdmissionSummary {
591        summary.lane = self.lane;
592
593        match self.rejection_for_summary(&summary) {
594            Some(rejection) => summary.reject(rejection),
595            None => summary.admit(),
596        }
597    }
598
599    fn rejection_for_summary(
600        &self,
601        summary: &QueryAdmissionSummary,
602    ) -> Option<QueryAdmissionRejection> {
603        if !self.lane.executes_rows() {
604            return Some(QueryAdmissionRejection::DiagnosticLaneDoesNotExecute);
605        }
606
607        if matches!(summary.plan_shape(), QueryAdmissionPlanShape::Delete) {
608            return Some(QueryAdmissionRejection::UnsupportedStatementForQueryLane);
609        }
610
611        if matches!(
612            summary.plan_shape(),
613            QueryAdmissionPlanShape::GroupedAggregate
614        ) && !self.grouped.has_hard_limits()
615        {
616            return Some(QueryAdmissionRejection::GroupedQueryRequiresLimits);
617        }
618
619        if self.require_limit() && summary.limit().is_none() {
620            return Some(QueryAdmissionRejection::PublicQueryRequiresLimit);
621        }
622
623        if self.reject_non_zero_offset() && summary.offset().unwrap_or_default() != 0 {
624            return Some(QueryAdmissionRejection::PublicQueryOffsetRejected);
625        }
626
627        if let Some(rejection) = self.returned_row_bound_rejection(summary) {
628            return Some(rejection);
629        }
630
631        if !self.allow_full_scan() && summary.selected_access().is_full_scan() {
632            return Some(QueryAdmissionRejection::UnboundedFullScanRejected);
633        }
634
635        if self.require_index()
636            && !access_satisfies_index_requirement(summary.selected_access(), summary.scan_bound())
637        {
638            return Some(QueryAdmissionRejection::PublicQueryRequiresIndex);
639        }
640
641        if let Some(rejection) = self.scan_bound_rejection(summary) {
642            return Some(rejection);
643        }
644
645        self.materialization_rejection(summary)
646    }
647
648    fn returned_row_bound_rejection(
649        &self,
650        summary: &QueryAdmissionSummary,
651    ) -> Option<QueryAdmissionRejection> {
652        let max_returned_rows = self.max_returned_rows?;
653
654        if matches!(
655            summary.returned_row_bound_kind(),
656            QueryBoundKind::EstimateOnly
657        ) {
658            return Some(QueryAdmissionRejection::EstimatedOnlyBoundRejected);
659        }
660
661        if !summary.returned_row_bound_kind().admits_public_read() {
662            return Some(QueryAdmissionRejection::ScanBoundUnavailable);
663        }
664
665        let Some(returned_row_bound) = summary.returned_row_bound() else {
666            return Some(QueryAdmissionRejection::ScanBoundUnavailable);
667        };
668
669        if returned_row_bound > max_returned_rows.get() {
670            return Some(QueryAdmissionRejection::ReturnedRowBoundExceedsPolicy);
671        }
672
673        None
674    }
675
676    fn scan_bound_rejection(
677        &self,
678        summary: &QueryAdmissionSummary,
679    ) -> Option<QueryAdmissionRejection> {
680        let max_scanned_rows = self.max_scanned_rows?;
681
682        if matches!(summary.scan_bound_kind(), QueryBoundKind::EstimateOnly) {
683            return Some(QueryAdmissionRejection::EstimatedOnlyBoundRejected);
684        }
685
686        if !summary.scan_bound_kind().admits_public_read() {
687            return Some(QueryAdmissionRejection::ScanBoundUnavailable);
688        }
689
690        let Some(scan_bound) = summary.scan_bound() else {
691            return Some(QueryAdmissionRejection::ScanBoundUnavailable);
692        };
693
694        if scan_bound > max_scanned_rows.get() {
695            return Some(QueryAdmissionRejection::ScanBoundExceedsPolicy);
696        }
697
698        None
699    }
700
701    fn materialization_rejection(
702        &self,
703        summary: &QueryAdmissionSummary,
704    ) -> Option<QueryAdmissionRejection> {
705        if !self.allow_materialized_sort() && summary.materialization().materialized_sort() {
706            return Some(QueryAdmissionRejection::SortRequiresMaterialization);
707        }
708
709        let max_materialized_rows = self.max_materialized_rows?;
710        let materialized_rows = summary.materialization().materialized_rows()?;
711
712        if materialized_rows > max_materialized_rows.get() {
713            Some(QueryAdmissionRejection::MaterializationExceedsBudget)
714        } else {
715            None
716        }
717    }
718}
719
720/// Materialization facts relevant to read admission.
721#[derive(Clone, Copy, Debug, Eq, PartialEq)]
722pub struct QueryMaterializationSummary {
723    materialized_sort: bool,
724    materialized_rows: Option<u32>,
725    row_bound_kind: QueryBoundKind,
726}
727
728impl QueryMaterializationSummary {
729    /// Build a summary for a plan that does not materialize rows for sorting.
730    #[must_use]
731    pub const fn none() -> Self {
732        Self {
733            materialized_sort: false,
734            materialized_rows: None,
735            row_bound_kind: QueryBoundKind::Unavailable,
736        }
737    }
738
739    /// Build a summary for a plan that materializes rows for sorting.
740    #[must_use]
741    pub const fn sort(materialized_rows: Option<u32>, row_bound_kind: QueryBoundKind) -> Self {
742        Self {
743            materialized_sort: true,
744            materialized_rows,
745            row_bound_kind,
746        }
747    }
748
749    /// Return whether the plan materializes rows for sorting.
750    #[must_use]
751    pub const fn materialized_sort(&self) -> bool {
752        self.materialized_sort
753    }
754
755    /// Return the row materialization bound, if known.
756    #[must_use]
757    pub const fn materialized_rows(&self) -> Option<u32> {
758        self.materialized_rows
759    }
760
761    /// Return the quality of the materialization row bound.
762    #[must_use]
763    pub const fn row_bound_kind(&self) -> QueryBoundKind {
764        self.row_bound_kind
765    }
766}
767
768/// Stable read-admission rejection reason.
769#[derive(Clone, Copy, Debug, Eq, PartialEq)]
770pub enum QueryAdmissionRejection {
771    /// Public reads require an explicit LIMIT.
772    PublicQueryRequiresLimit,
773    /// Public reads require a proven index-backed access path.
774    PublicQueryRequiresIndex,
775    /// The selected plan is an unbounded full scan.
776    UnboundedFullScanRejected,
777    /// No scan bound was available for a policy that requires one.
778    ScanBoundUnavailable,
779    /// The proven scan bound exceeds the policy.
780    ScanBoundExceedsPolicy,
781    /// Only an estimate was available for a policy that requires proof.
782    EstimatedOnlyBoundRejected,
783    /// ORDER BY requires materializing rows.
784    SortRequiresMaterialization,
785    /// Materialization exceeds the policy.
786    MaterializationExceedsBudget,
787    /// Projection bytes may exceed the response budget.
788    ProjectionResponseMayExceedLimit,
789    /// Grouped reads need explicit group and memory budgets.
790    GroupedQueryRequiresLimits,
791    /// Grouped read planning exceeds the policy.
792    GroupedQueryExceedsBudget,
793    /// Diagnostic lanes do not execute rows.
794    DiagnosticLaneDoesNotExecute,
795    /// Introspection is disabled for the selected lane.
796    IntrospectionDisabledForLane,
797    /// The statement shape is not supported by the selected lane.
798    UnsupportedStatementForQueryLane,
799    /// Public read endpoints do not permit non-zero OFFSET execution.
800    PublicQueryOffsetRejected,
801    /// The returned-row bound exceeds the selected policy.
802    ReturnedRowBoundExceedsPolicy,
803}
804
805impl QueryAdmissionRejection {
806    /// Return a stable lowercase diagnostic label for this rejection.
807    #[must_use]
808    pub const fn as_str(self) -> &'static str {
809        match self {
810            Self::PublicQueryRequiresLimit => "public_query_requires_limit",
811            Self::PublicQueryRequiresIndex => "public_query_requires_index",
812            Self::UnboundedFullScanRejected => "unbounded_full_scan_rejected",
813            Self::ScanBoundUnavailable => "scan_bound_unavailable",
814            Self::ScanBoundExceedsPolicy => "scan_bound_exceeds_policy",
815            Self::EstimatedOnlyBoundRejected => "estimated_only_bound_rejected",
816            Self::SortRequiresMaterialization => "sort_requires_materialization",
817            Self::MaterializationExceedsBudget => "materialization_exceeds_budget",
818            Self::ProjectionResponseMayExceedLimit => "projection_response_may_exceed_limit",
819            Self::GroupedQueryRequiresLimits => "grouped_query_requires_limits",
820            Self::GroupedQueryExceedsBudget => "grouped_query_exceeds_budget",
821            Self::DiagnosticLaneDoesNotExecute => "diagnostic_lane_does_not_execute",
822            Self::IntrospectionDisabledForLane => "introspection_disabled_for_lane",
823            Self::UnsupportedStatementForQueryLane => "unsupported_statement_for_query_lane",
824            Self::PublicQueryOffsetRejected => "public_query_offset_rejected",
825            Self::ReturnedRowBoundExceedsPolicy => "returned_row_bound_exceeds_policy",
826        }
827    }
828
829    /// Return the compact diagnostic detail code for this rejection.
830    #[must_use]
831    pub const fn code(self) -> QueryReadAdmissionCode {
832        match self {
833            Self::PublicQueryRequiresLimit => QueryReadAdmissionCode::PublicQueryRequiresLimit,
834            Self::PublicQueryRequiresIndex => QueryReadAdmissionCode::PublicQueryRequiresIndex,
835            Self::UnboundedFullScanRejected => QueryReadAdmissionCode::UnboundedFullScanRejected,
836            Self::ScanBoundUnavailable => QueryReadAdmissionCode::ScanBoundUnavailable,
837            Self::ScanBoundExceedsPolicy => QueryReadAdmissionCode::ScanBoundExceedsPolicy,
838            Self::EstimatedOnlyBoundRejected => QueryReadAdmissionCode::EstimatedOnlyBoundRejected,
839            Self::SortRequiresMaterialization => {
840                QueryReadAdmissionCode::SortRequiresMaterialization
841            }
842            Self::MaterializationExceedsBudget => {
843                QueryReadAdmissionCode::MaterializationExceedsBudget
844            }
845            Self::ProjectionResponseMayExceedLimit => {
846                QueryReadAdmissionCode::ProjectionResponseMayExceedLimit
847            }
848            Self::GroupedQueryRequiresLimits => QueryReadAdmissionCode::GroupedQueryRequiresLimits,
849            Self::GroupedQueryExceedsBudget => QueryReadAdmissionCode::GroupedQueryExceedsBudget,
850            Self::DiagnosticLaneDoesNotExecute => {
851                QueryReadAdmissionCode::DiagnosticLaneDoesNotExecute
852            }
853            Self::IntrospectionDisabledForLane => {
854                QueryReadAdmissionCode::IntrospectionDisabledForLane
855            }
856            Self::UnsupportedStatementForQueryLane => {
857                QueryReadAdmissionCode::UnsupportedStatementForQueryLane
858            }
859            Self::PublicQueryOffsetRejected => QueryReadAdmissionCode::PublicQueryOffsetRejected,
860            Self::ReturnedRowBoundExceedsPolicy => {
861                QueryReadAdmissionCode::ReturnedRowBoundExceedsPolicy
862            }
863        }
864    }
865
866    /// Return a compact diagnostic payload for this rejection.
867    #[must_use]
868    pub const fn diagnostic(self) -> Diagnostic {
869        Diagnostic::new(
870            DiagnosticCode::QueryReadAdmission,
871            ErrorOrigin::Query,
872            Some(DiagnosticDetail::QueryReadAdmission {
873                reason: self.code(),
874            }),
875        )
876    }
877
878    /// Return the public wire code for this rejection.
879    #[must_use]
880    pub const fn error_code(self) -> ErrorCode {
881        self.diagnostic().error_code()
882    }
883}
884
885/// Read-admission result and plan facts for diagnostics and EXPLAIN.
886#[derive(Clone, Debug, Eq, PartialEq)]
887pub struct QueryAdmissionSummary {
888    lane: QueryAdmissionLane,
889    decision: QueryAdmissionDecision,
890    plan_shape: QueryAdmissionPlanShape,
891    selected_access: QueryAdmissionAccessKind,
892    selected_index: Option<String>,
893    limit: Option<u32>,
894    offset: Option<u32>,
895    scan_bound: Option<u64>,
896    scan_bound_kind: QueryBoundKind,
897    returned_row_bound: Option<u32>,
898    returned_row_bound_kind: QueryBoundKind,
899    response_byte_bound: Option<u32>,
900    response_byte_bound_kind: QueryBoundKind,
901    residual_filter: QueryAdmissionResidualFilter,
902    ordering: QueryAdmissionOrdering,
903    grouped: Option<QueryAdmissionGroupedSummary>,
904    materialization: QueryMaterializationSummary,
905    rejection: Option<QueryAdmissionRejection>,
906}
907
908impl QueryAdmissionSummary {
909    /// Build an admitted summary with unknown bound details.
910    #[must_use]
911    pub const fn admitted(
912        lane: QueryAdmissionLane,
913        selected_access: QueryAdmissionAccessKind,
914    ) -> Self {
915        Self {
916            lane,
917            decision: QueryAdmissionDecision::Admitted,
918            plan_shape: QueryAdmissionPlanShape::ScalarRead,
919            selected_access,
920            selected_index: None,
921            limit: None,
922            offset: None,
923            scan_bound: None,
924            scan_bound_kind: QueryBoundKind::Unavailable,
925            returned_row_bound: None,
926            returned_row_bound_kind: QueryBoundKind::Unavailable,
927            response_byte_bound: None,
928            response_byte_bound_kind: QueryBoundKind::Unavailable,
929            residual_filter: QueryAdmissionResidualFilter::Absent,
930            ordering: QueryAdmissionOrdering::None,
931            grouped: None,
932            materialization: QueryMaterializationSummary::none(),
933            rejection: None,
934        }
935    }
936
937    /// Build a rejected summary with unknown bound details.
938    #[must_use]
939    pub const fn rejected(
940        lane: QueryAdmissionLane,
941        selected_access: QueryAdmissionAccessKind,
942        rejection: QueryAdmissionRejection,
943    ) -> Self {
944        Self {
945            lane,
946            decision: QueryAdmissionDecision::Rejected,
947            plan_shape: QueryAdmissionPlanShape::ScalarRead,
948            selected_access,
949            selected_index: None,
950            limit: None,
951            offset: None,
952            scan_bound: None,
953            scan_bound_kind: QueryBoundKind::Unavailable,
954            returned_row_bound: None,
955            returned_row_bound_kind: QueryBoundKind::Unavailable,
956            response_byte_bound: None,
957            response_byte_bound_kind: QueryBoundKind::Unavailable,
958            residual_filter: QueryAdmissionResidualFilter::Absent,
959            ordering: QueryAdmissionOrdering::None,
960            grouped: None,
961            materialization: QueryMaterializationSummary::none(),
962            rejection: Some(rejection),
963        }
964    }
965
966    /// Build one admitted summary from the already-selected access plan.
967    #[must_use]
968    pub(in crate::db) fn from_plan(lane: QueryAdmissionLane, plan: &AccessPlannedQuery) -> Self {
969        let access = summarize_access_plan(plan);
970        let grouped = plan.grouped_plan().map(summarize_grouped_plan);
971        let (limit, offset) = scalar_limit_and_offset(plan.scalar_plan());
972        let (returned_row_bound, returned_row_bound_kind) =
973            returned_row_bound_from_plan(limit, grouped);
974        let scan_bound_kind = access.scan_bound_kind();
975        Self {
976            lane,
977            decision: QueryAdmissionDecision::Admitted,
978            plan_shape: plan_shape(plan),
979            selected_access: access.kind,
980            selected_index: access.selected_index,
981            limit,
982            offset: Some(offset),
983            scan_bound: access.exact_scan_bound,
984            scan_bound_kind,
985            returned_row_bound,
986            returned_row_bound_kind,
987            response_byte_bound: None,
988            response_byte_bound_kind: QueryBoundKind::Unavailable,
989            residual_filter: admission_residual_filter(plan.residual_filter_shape()),
990            ordering: admission_ordering(plan),
991            grouped,
992            materialization: QueryMaterializationSummary::none(),
993            rejection: None,
994        }
995    }
996
997    const fn admit(mut self) -> Self {
998        self.decision = QueryAdmissionDecision::Admitted;
999        self.rejection = None;
1000        self
1001    }
1002
1003    const fn reject(mut self, rejection: QueryAdmissionRejection) -> Self {
1004        self.decision = QueryAdmissionDecision::Rejected;
1005        self.rejection = Some(rejection);
1006        self
1007    }
1008
1009    /// Return the admission lane.
1010    #[must_use]
1011    pub const fn lane(&self) -> QueryAdmissionLane {
1012        self.lane
1013    }
1014
1015    /// Return the final decision.
1016    #[must_use]
1017    pub const fn decision(&self) -> QueryAdmissionDecision {
1018        self.decision
1019    }
1020
1021    /// Return the scalar/grouped statement shape.
1022    #[must_use]
1023    pub const fn plan_shape(&self) -> QueryAdmissionPlanShape {
1024        self.plan_shape
1025    }
1026
1027    /// Return the selected access class.
1028    #[must_use]
1029    pub const fn selected_access(&self) -> QueryAdmissionAccessKind {
1030        self.selected_access
1031    }
1032
1033    /// Return the selected index name, if one exists.
1034    #[must_use]
1035    pub fn selected_index(&self) -> Option<&str> {
1036        self.selected_index.as_deref()
1037    }
1038
1039    /// Return the caller-visible LIMIT, if present.
1040    #[must_use]
1041    pub const fn limit(&self) -> Option<u32> {
1042        self.limit
1043    }
1044
1045    /// Return the caller-visible OFFSET, if present.
1046    #[must_use]
1047    pub const fn offset(&self) -> Option<u32> {
1048        self.offset
1049    }
1050
1051    /// Return the scan bound, if known.
1052    #[must_use]
1053    pub const fn scan_bound(&self) -> Option<u64> {
1054        self.scan_bound
1055    }
1056
1057    /// Return the quality of the scan bound.
1058    #[must_use]
1059    pub const fn scan_bound_kind(&self) -> QueryBoundKind {
1060        self.scan_bound_kind
1061    }
1062
1063    /// Return the returned-row bound, if known.
1064    #[must_use]
1065    pub const fn returned_row_bound(&self) -> Option<u32> {
1066        self.returned_row_bound
1067    }
1068
1069    /// Return the quality of the returned-row bound.
1070    #[must_use]
1071    pub const fn returned_row_bound_kind(&self) -> QueryBoundKind {
1072        self.returned_row_bound_kind
1073    }
1074
1075    /// Return the response-byte bound, if known.
1076    #[must_use]
1077    pub const fn response_byte_bound(&self) -> Option<u32> {
1078        self.response_byte_bound
1079    }
1080
1081    /// Return the quality of the response-byte bound.
1082    #[must_use]
1083    pub const fn response_byte_bound_kind(&self) -> QueryBoundKind {
1084        self.response_byte_bound_kind
1085    }
1086
1087    /// Return post-access residual filter facts.
1088    #[must_use]
1089    pub const fn residual_filter(&self) -> QueryAdmissionResidualFilter {
1090        self.residual_filter
1091    }
1092
1093    /// Return ORDER BY facts.
1094    #[must_use]
1095    pub const fn ordering(&self) -> QueryAdmissionOrdering {
1096        self.ordering
1097    }
1098
1099    /// Return grouped query facts, if this is a grouped plan.
1100    #[must_use]
1101    pub const fn grouped(&self) -> Option<QueryAdmissionGroupedSummary> {
1102        self.grouped
1103    }
1104
1105    /// Return materialization facts.
1106    #[must_use]
1107    pub const fn materialization(&self) -> QueryMaterializationSummary {
1108        self.materialization
1109    }
1110
1111    /// Return a copy of this summary with route-derived materialization facts attached.
1112    #[must_use]
1113    #[cfg_attr(not(feature = "sql"), allow(dead_code))]
1114    pub(in crate::db) const fn with_materialization(
1115        mut self,
1116        materialization: QueryMaterializationSummary,
1117    ) -> Self {
1118        self.materialization = materialization;
1119        self
1120    }
1121
1122    /// Return the rejection reason, when the decision is rejected.
1123    #[must_use]
1124    pub const fn rejection(&self) -> Option<QueryAdmissionRejection> {
1125        self.rejection
1126    }
1127
1128    /// Render this summary as a stable top-level verbose EXPLAIN block.
1129    #[must_use]
1130    pub(in crate::db) fn render_text_block(&self) -> String {
1131        let mut out = String::from("admission:");
1132        push_text_field(&mut out, "lane", self.lane().as_str());
1133        push_text_field(&mut out, "decision", self.decision().as_str());
1134        push_text_field(
1135            &mut out,
1136            "reason",
1137            self.rejection()
1138                .map_or("none", QueryAdmissionRejection::as_str),
1139        );
1140        push_text_field(&mut out, "plan_shape", self.plan_shape().as_str());
1141        push_text_field(&mut out, "selected_access", self.selected_access().as_str());
1142        push_text_field(
1143            &mut out,
1144            "selected_index",
1145            self.selected_index().unwrap_or("none"),
1146        );
1147        push_text_option_u32(&mut out, "limit", self.limit());
1148        push_text_option_u32(&mut out, "offset", self.offset());
1149        push_text_option_u64(&mut out, "scan_bound", self.scan_bound());
1150        push_text_field(&mut out, "scan_bound_kind", self.scan_bound_kind().as_str());
1151        push_text_option_u32(&mut out, "returned_row_bound", self.returned_row_bound());
1152        push_text_field(
1153            &mut out,
1154            "returned_row_bound_kind",
1155            self.returned_row_bound_kind().as_str(),
1156        );
1157        push_text_option_u32(&mut out, "response_byte_bound", self.response_byte_bound());
1158        push_text_field(
1159            &mut out,
1160            "response_byte_bound_kind",
1161            self.response_byte_bound_kind().as_str(),
1162        );
1163        push_text_field(&mut out, "residual_filter", self.residual_filter().as_str());
1164        push_text_field(&mut out, "ordering", self.ordering().as_str());
1165        push_text_bool(
1166            &mut out,
1167            "materialized_sort",
1168            self.materialization().materialized_sort(),
1169        );
1170        push_text_option_u32(
1171            &mut out,
1172            "materialized_rows",
1173            self.materialization().materialized_rows(),
1174        );
1175        push_text_field(
1176            &mut out,
1177            "materialized_row_bound_kind",
1178            self.materialization().row_bound_kind().as_str(),
1179        );
1180
1181        if let Some(grouped) = self.grouped() {
1182            push_text_bool(&mut out, "grouped", true);
1183            push_text_u64(
1184                &mut out,
1185                "group_field_count",
1186                u64::from(grouped.group_field_count()),
1187            );
1188            push_text_u64(
1189                &mut out,
1190                "aggregate_count",
1191                u64::from(grouped.aggregate_count()),
1192            );
1193            push_text_u64(&mut out, "max_groups", grouped.max_groups());
1194            push_text_u64(&mut out, "max_group_bytes", grouped.max_group_bytes());
1195            push_text_bool(&mut out, "having_filter", grouped.has_having_filter());
1196        } else {
1197            push_text_bool(&mut out, "grouped", false);
1198        }
1199
1200        out
1201    }
1202}
1203
1204fn push_text_field(out: &mut String, key: &str, value: &str) {
1205    out.push('\n');
1206    out.push_str("  ");
1207    out.push_str(key);
1208    out.push('=');
1209    out.push_str(value);
1210}
1211
1212fn push_text_bool(out: &mut String, key: &str, value: bool) {
1213    push_text_field(out, key, if value { "true" } else { "false" });
1214}
1215
1216fn push_text_u64(out: &mut String, key: &str, value: u64) {
1217    out.push('\n');
1218    out.push_str("  ");
1219    out.push_str(key);
1220    out.push('=');
1221    let _ = write!(out, "{value}");
1222}
1223
1224fn push_text_option_u32(out: &mut String, key: &str, value: Option<u32>) {
1225    match value {
1226        Some(value) => push_text_u64(out, key, u64::from(value)),
1227        None => push_text_field(out, key, "none"),
1228    }
1229}
1230
1231fn push_text_option_u64(out: &mut String, key: &str, value: Option<u64>) {
1232    match value {
1233        Some(value) => push_text_u64(out, key, value),
1234        None => push_text_field(out, key, "none"),
1235    }
1236}
1237
1238// Keep the staged extractor live before admission enforcement calls it directly.
1239const _: fn(QueryAdmissionLane, &AccessPlannedQuery) -> QueryAdmissionSummary =
1240    QueryAdmissionSummary::from_plan;
1241
1242const fn access_satisfies_index_requirement(
1243    kind: QueryAdmissionAccessKind,
1244    scan_bound: Option<u64>,
1245) -> bool {
1246    kind.is_secondary_index()
1247        || matches!(
1248            (kind, scan_bound),
1249            (
1250                QueryAdmissionAccessKind::ByKey | QueryAdmissionAccessKind::ByKeys,
1251                Some(_)
1252            )
1253        )
1254}
1255
1256struct AdmissionAccessProjection;
1257
1258#[derive(Clone, Debug, Eq, PartialEq)]
1259struct AdmissionAccessSummary {
1260    kind: QueryAdmissionAccessKind,
1261    selected_index: Option<String>,
1262    exact_scan_bound: Option<u64>,
1263}
1264
1265impl AdmissionAccessSummary {
1266    const fn non_index(kind: QueryAdmissionAccessKind, exact_scan_bound: Option<u64>) -> Self {
1267        Self {
1268            kind,
1269            selected_index: None,
1270            exact_scan_bound,
1271        }
1272    }
1273
1274    fn secondary_index(kind: QueryAdmissionAccessKind, index_name: &str) -> Self {
1275        Self {
1276            kind,
1277            selected_index: Some(index_name.to_string()),
1278            exact_scan_bound: None,
1279        }
1280    }
1281
1282    const fn composite(kind: QueryAdmissionAccessKind) -> Self {
1283        Self {
1284            kind,
1285            selected_index: None,
1286            exact_scan_bound: None,
1287        }
1288    }
1289
1290    const fn scan_bound_kind(&self) -> QueryBoundKind {
1291        if self.exact_scan_bound.is_some() {
1292            QueryBoundKind::Exact
1293        } else {
1294            QueryBoundKind::Unavailable
1295        }
1296    }
1297}
1298
1299impl AccessPlanProjection<Value> for AdmissionAccessProjection {
1300    type Output = AdmissionAccessSummary;
1301
1302    fn by_key(&mut self, _key: &Value) -> Self::Output {
1303        AdmissionAccessSummary::non_index(QueryAdmissionAccessKind::ByKey, Some(1))
1304    }
1305
1306    fn by_keys(&mut self, keys: &[Value]) -> Self::Output {
1307        AdmissionAccessSummary::non_index(
1308            QueryAdmissionAccessKind::ByKeys,
1309            Some(u64::try_from(keys.len()).unwrap_or(u64::MAX)),
1310        )
1311    }
1312
1313    fn key_range(&mut self, _start: &Value, _end: &Value) -> Self::Output {
1314        AdmissionAccessSummary::non_index(QueryAdmissionAccessKind::KeyRange, None)
1315    }
1316
1317    fn index_prefix(
1318        &mut self,
1319        index_name: &str,
1320        _index_fields: &[String],
1321        _prefix_len: usize,
1322        _values: &[Value],
1323    ) -> Self::Output {
1324        AdmissionAccessSummary::secondary_index(QueryAdmissionAccessKind::IndexPrefix, index_name)
1325    }
1326
1327    fn index_multi_lookup(
1328        &mut self,
1329        index_name: &str,
1330        _index_fields: &[String],
1331        _values: &[Value],
1332    ) -> Self::Output {
1333        AdmissionAccessSummary::secondary_index(
1334            QueryAdmissionAccessKind::IndexMultiLookup,
1335            index_name,
1336        )
1337    }
1338
1339    fn index_branch_set(
1340        &mut self,
1341        index_name: &str,
1342        _index_fields: &[String],
1343        _fixed_values: &[Value],
1344        _branch_values: &[Value],
1345        _ordered_suffix: IndexBranchSetOrderedSuffix,
1346    ) -> Self::Output {
1347        AdmissionAccessSummary::secondary_index(
1348            QueryAdmissionAccessKind::IndexBranchSet,
1349            index_name,
1350        )
1351    }
1352
1353    fn index_range(
1354        &mut self,
1355        index_name: &str,
1356        _index_fields: &[String],
1357        _prefix_len: usize,
1358        _prefix: &[Value],
1359        _lower: &Bound<Value>,
1360        _upper: &Bound<Value>,
1361    ) -> Self::Output {
1362        AdmissionAccessSummary::secondary_index(QueryAdmissionAccessKind::IndexRange, index_name)
1363    }
1364
1365    fn full_scan(&mut self) -> Self::Output {
1366        AdmissionAccessSummary::non_index(QueryAdmissionAccessKind::FullScan, None)
1367    }
1368
1369    fn union(&mut self, _children: Vec<Self::Output>) -> Self::Output {
1370        AdmissionAccessSummary::composite(QueryAdmissionAccessKind::Union)
1371    }
1372
1373    fn intersection(&mut self, _children: Vec<Self::Output>) -> Self::Output {
1374        AdmissionAccessSummary::composite(QueryAdmissionAccessKind::Intersection)
1375    }
1376}
1377
1378fn summarize_access_plan(plan: &AccessPlannedQuery) -> AdmissionAccessSummary {
1379    project_access_plan(&plan.access, &mut AdmissionAccessProjection)
1380}
1381
1382fn summarize_grouped_plan(plan: &GroupPlan) -> QueryAdmissionGroupedSummary {
1383    QueryAdmissionGroupedSummary::new(
1384        u32::try_from(plan.group.group_fields.len()).unwrap_or(u32::MAX),
1385        u32::try_from(plan.group.aggregates.len()).unwrap_or(u32::MAX),
1386        plan.group.execution.max_groups(),
1387        plan.group.execution.max_group_bytes(),
1388        plan.having_expr.is_some(),
1389    )
1390}
1391
1392const fn scalar_limit_and_offset(plan: &ScalarPlan) -> (Option<u32>, u32) {
1393    match plan.mode {
1394        QueryMode::Load(load) => match &plan.page {
1395            Some(page) => (page.limit, page.offset),
1396            None => (load.limit(), load.offset()),
1397        },
1398        QueryMode::Delete(delete) => match plan.delete_limit {
1399            Some(delete_limit) => (delete_limit.limit, delete_limit.offset),
1400            None => (delete.limit(), delete.offset()),
1401        },
1402    }
1403}
1404
1405fn returned_row_bound_from_plan(
1406    limit: Option<u32>,
1407    grouped: Option<QueryAdmissionGroupedSummary>,
1408) -> (Option<u32>, QueryBoundKind) {
1409    if let Some(limit) = limit {
1410        return (Some(limit), QueryBoundKind::EnforcedRuntimeCap);
1411    }
1412
1413    let Some(grouped) = grouped else {
1414        return (None, QueryBoundKind::Unavailable);
1415    };
1416    if grouped.max_groups() == u64::MAX {
1417        return (None, QueryBoundKind::Unavailable);
1418    }
1419
1420    (
1421        Some(u32::try_from(grouped.max_groups()).unwrap_or(u32::MAX)),
1422        QueryBoundKind::ConservativeUpperBound,
1423    )
1424}
1425
1426const fn admission_residual_filter(shape: ResidualFilterShape) -> QueryAdmissionResidualFilter {
1427    match shape {
1428        ResidualFilterShape::Absent => QueryAdmissionResidualFilter::Absent,
1429        ResidualFilterShape::Predicate => QueryAdmissionResidualFilter::Predicate,
1430        ResidualFilterShape::Expression => QueryAdmissionResidualFilter::Expression,
1431        ResidualFilterShape::ExpressionAndPredicate => {
1432            QueryAdmissionResidualFilter::ExpressionAndPredicate
1433        }
1434    }
1435}
1436
1437fn admission_ordering(plan: &AccessPlannedQuery) -> QueryAdmissionOrdering {
1438    if plan.scalar_plan().order.is_none() {
1439        return QueryAdmissionOrdering::None;
1440    }
1441
1442    if plan.resolved_order().is_some() {
1443        QueryAdmissionOrdering::Resolved
1444    } else {
1445        QueryAdmissionOrdering::Requested
1446    }
1447}
1448
1449const fn plan_shape(plan: &AccessPlannedQuery) -> QueryAdmissionPlanShape {
1450    if plan.grouped_plan().is_some() {
1451        return QueryAdmissionPlanShape::GroupedAggregate;
1452    }
1453
1454    match plan.scalar_plan().mode {
1455        QueryMode::Load(_) => QueryAdmissionPlanShape::ScalarRead,
1456        QueryMode::Delete(_) => QueryAdmissionPlanShape::Delete,
1457    }
1458}
1459
1460#[cfg(test)]
1461mod tests {
1462    use std::num::{NonZeroU32, NonZeroU64};
1463
1464    use crate::{
1465        db::{
1466            access::{AccessPath, SemanticIndexAccessContract},
1467            predicate::{MissingRowPolicy, Predicate},
1468            query::plan::{
1469                AccessPlannedQuery, AggregateKind, DeleteLimitSpec, DeleteSpec, FieldSlot,
1470                GroupAggregateSpec, GroupSpec, GroupedExecutionConfig, OrderDirection, OrderSpec,
1471                OrderTerm, PageSpec, QueryMode,
1472                expr::{Expr, FieldId},
1473            },
1474        },
1475        model::index::IndexModel,
1476        value::Value,
1477    };
1478
1479    use super::{
1480        GroupedAdmissionPolicy, QueryAdmissionAccessKind, QueryAdmissionDecision,
1481        QueryAdmissionLane, QueryAdmissionOrdering, QueryAdmissionPlanShape, QueryAdmissionPolicy,
1482        QueryAdmissionRejection, QueryAdmissionResidualFilter, QueryAdmissionSummary,
1483        QueryBoundKind, QueryMaterializationSummary,
1484    };
1485
1486    const ADMISSION_INDEX_FIELDS: [&str; 1] = ["tag"];
1487    const ADMISSION_INDEX: IndexModel = IndexModel::generated(
1488        "admission::tag",
1489        "admission::tag_store",
1490        &ADMISSION_INDEX_FIELDS,
1491        false,
1492    );
1493
1494    #[test]
1495    fn public_read_policy_has_safe_finite_defaults() {
1496        let max_rows = NonZeroU32::new(50).expect("test max rows is non-zero");
1497        let max_bytes = NonZeroU32::new(32_768).expect("test max bytes is non-zero");
1498        let policy = QueryAdmissionPolicy::public_read(max_rows, max_bytes);
1499
1500        assert_eq!(policy.lane(), QueryAdmissionLane::PublicRead);
1501        assert!(policy.require_limit());
1502        assert!(policy.require_index());
1503        assert!(policy.reject_non_zero_offset());
1504        assert!(!policy.allow_full_scan());
1505        assert!(!policy.allow_materialized_sort());
1506        assert_eq!(policy.max_returned_rows(), Some(max_rows));
1507        assert_eq!(policy.max_response_bytes(), Some(max_bytes));
1508        assert!(policy.public_caps_are_finite());
1509        assert!(!policy.grouped().has_hard_limits());
1510    }
1511
1512    #[test]
1513    fn admin_policy_is_broader_but_still_budgeted() {
1514        let max_rows = NonZeroU32::new(100).expect("test max rows is non-zero");
1515        let max_scanned = NonZeroU64::new(1_000).expect("test scan cap is non-zero");
1516        let max_bytes = NonZeroU32::new(65_536).expect("test max bytes is non-zero");
1517        let policy = QueryAdmissionPolicy::admin_ad_hoc(max_rows, max_scanned, max_bytes);
1518
1519        assert_eq!(policy.lane(), QueryAdmissionLane::AdminAdHoc);
1520        assert!(!policy.require_limit());
1521        assert!(!policy.require_index());
1522        assert!(policy.allow_full_scan());
1523        assert!(policy.allow_materialized_sort());
1524        assert_eq!(policy.max_scanned_rows(), Some(max_scanned));
1525        assert_eq!(policy.max_materialized_rows(), Some(max_rows));
1526    }
1527
1528    #[test]
1529    fn diagnostic_explain_lane_does_not_execute_rows() {
1530        let policy = QueryAdmissionPolicy::diagnostic_explain();
1531
1532        assert_eq!(policy.lane().as_str(), "diagnostic_explain");
1533        assert!(!policy.lane().executes_rows());
1534    }
1535
1536    #[test]
1537    fn grouped_policy_requires_group_and_memory_budgets() {
1538        let max_groups = NonZeroU32::new(8).expect("test group cap is non-zero");
1539        let max_bytes = NonZeroU32::new(4096).expect("test byte cap is non-zero");
1540        let policy = GroupedAdmissionPolicy::bounded(max_groups, max_bytes, None);
1541
1542        assert!(policy.has_hard_limits());
1543        assert_eq!(policy.max_groups(), Some(max_groups));
1544        assert_eq!(policy.max_group_bytes(), Some(max_bytes));
1545    }
1546
1547    #[test]
1548    fn only_proven_or_enforced_bounds_admit_public_reads() {
1549        assert!(QueryBoundKind::Exact.admits_public_read());
1550        assert!(QueryBoundKind::ConservativeUpperBound.admits_public_read());
1551        assert!(QueryBoundKind::EnforcedRuntimeCap.admits_public_read());
1552        assert!(!QueryBoundKind::EstimateOnly.admits_public_read());
1553        assert!(!QueryBoundKind::Unavailable.admits_public_read());
1554    }
1555
1556    #[test]
1557    fn access_kind_classifies_secondary_indexes_and_full_scans() {
1558        assert!(QueryAdmissionAccessKind::IndexPrefix.is_secondary_index());
1559        assert!(QueryAdmissionAccessKind::FullScan.is_full_scan());
1560        assert!(!QueryAdmissionAccessKind::ByKey.is_secondary_index());
1561    }
1562
1563    #[test]
1564    fn rejection_maps_to_stable_diagnostic() {
1565        let rejection = QueryAdmissionRejection::PublicQueryRequiresLimit;
1566        let diagnostic = rejection.diagnostic();
1567
1568        assert_eq!(
1569            rejection.error_code(),
1570            icydb_diagnostic_code::ErrorCode::QUERY_READ_PUBLIC_REQUIRES_LIMIT
1571        );
1572        assert_eq!(
1573            diagnostic.code(),
1574            icydb_diagnostic_code::DiagnosticCode::QueryReadAdmission
1575        );
1576    }
1577
1578    #[test]
1579    fn summaries_keep_decision_and_rejection_aligned() {
1580        let admitted = QueryAdmissionSummary::admitted(
1581            QueryAdmissionLane::PublicRead,
1582            QueryAdmissionAccessKind::ByKey,
1583        );
1584        let rejected = QueryAdmissionSummary::rejected(
1585            QueryAdmissionLane::PublicRead,
1586            QueryAdmissionAccessKind::FullScan,
1587            QueryAdmissionRejection::UnboundedFullScanRejected,
1588        );
1589
1590        assert_eq!(admitted.decision(), QueryAdmissionDecision::Admitted);
1591        assert_eq!(admitted.rejection(), None);
1592        assert_eq!(rejected.decision(), QueryAdmissionDecision::Rejected);
1593        assert_eq!(
1594            rejected.rejection(),
1595            Some(QueryAdmissionRejection::UnboundedFullScanRejected)
1596        );
1597    }
1598
1599    #[test]
1600    fn admission_summary_renders_stable_verbose_explain_block() {
1601        let summary = QueryAdmissionSummary::rejected(
1602            QueryAdmissionLane::PublicRead,
1603            QueryAdmissionAccessKind::FullScan,
1604            QueryAdmissionRejection::UnboundedFullScanRejected,
1605        );
1606
1607        let rendered = summary.render_text_block();
1608
1609        assert!(
1610            rendered.starts_with("admission:\n  lane=public_read\n  decision=rejected"),
1611            "admission block should start with stable lane and decision fields: {rendered}",
1612        );
1613        assert!(
1614            rendered.contains("\n  reason=unbounded_full_scan_rejected"),
1615            "admission block should include a stable rejection reason: {rendered}",
1616        );
1617        assert!(
1618            rendered.contains("\n  selected_access=full_scan"),
1619            "admission block should include the selected access class: {rendered}",
1620        );
1621        assert!(
1622            rendered.contains("\n  grouped=false"),
1623            "admission block should include grouped classification: {rendered}",
1624        );
1625    }
1626
1627    #[test]
1628    fn plan_summary_classifies_full_scan_without_overclaiming_bounds() {
1629        let plan = AccessPlannedQuery::new(AccessPath::<Value>::FullScan, MissingRowPolicy::Ignore);
1630
1631        let summary = QueryAdmissionSummary::from_plan(QueryAdmissionLane::PublicRead, &plan);
1632
1633        assert_eq!(summary.plan_shape(), QueryAdmissionPlanShape::ScalarRead);
1634        assert_eq!(
1635            summary.selected_access(),
1636            QueryAdmissionAccessKind::FullScan
1637        );
1638        assert_eq!(summary.selected_index(), None);
1639        assert_eq!(summary.limit(), None);
1640        assert_eq!(summary.offset(), Some(0));
1641        assert_eq!(summary.scan_bound(), None);
1642        assert_eq!(summary.scan_bound_kind(), QueryBoundKind::Unavailable);
1643        assert_eq!(summary.returned_row_bound(), None);
1644        assert_eq!(
1645            summary.returned_row_bound_kind(),
1646            QueryBoundKind::Unavailable
1647        );
1648        assert_eq!(
1649            summary.residual_filter(),
1650            QueryAdmissionResidualFilter::Absent
1651        );
1652        assert_eq!(summary.ordering(), QueryAdmissionOrdering::None);
1653    }
1654
1655    #[test]
1656    fn plan_summary_uses_point_lookup_and_limit_as_proven_bounds() {
1657        let mut plan =
1658            AccessPlannedQuery::new(AccessPath::ByKey(Value::Nat64(7)), MissingRowPolicy::Ignore);
1659        plan.scalar_plan_mut().page = Some(PageSpec {
1660            limit: Some(5),
1661            offset: 2,
1662        });
1663
1664        let summary = QueryAdmissionSummary::from_plan(QueryAdmissionLane::PublicRead, &plan);
1665
1666        assert_eq!(summary.selected_access(), QueryAdmissionAccessKind::ByKey);
1667        assert_eq!(summary.limit(), Some(5));
1668        assert_eq!(summary.offset(), Some(2));
1669        assert_eq!(summary.scan_bound(), Some(1));
1670        assert_eq!(summary.scan_bound_kind(), QueryBoundKind::Exact);
1671        assert_eq!(summary.returned_row_bound(), Some(5));
1672        assert_eq!(
1673            summary.returned_row_bound_kind(),
1674            QueryBoundKind::EnforcedRuntimeCap
1675        );
1676    }
1677
1678    #[test]
1679    fn plan_summary_preserves_selected_index_identity() {
1680        let plan = AccessPlannedQuery::new(
1681            AccessPath::IndexPrefix {
1682                index: SemanticIndexAccessContract::model_only_from_generated_index(
1683                    ADMISSION_INDEX,
1684                ),
1685                values: vec![Value::Text("alpha".to_string())],
1686            },
1687            MissingRowPolicy::Ignore,
1688        );
1689
1690        let summary = QueryAdmissionSummary::from_plan(QueryAdmissionLane::PublicRead, &plan);
1691
1692        assert_eq!(
1693            summary.selected_access(),
1694            QueryAdmissionAccessKind::IndexPrefix
1695        );
1696        assert_eq!(summary.selected_index(), Some("admission::tag"));
1697        assert_eq!(summary.scan_bound(), None);
1698        assert_eq!(summary.scan_bound_kind(), QueryBoundKind::Unavailable);
1699    }
1700
1701    #[test]
1702    fn plan_summary_classifies_residual_and_requested_ordering() {
1703        let mut plan =
1704            AccessPlannedQuery::new(AccessPath::<Value>::FullScan, MissingRowPolicy::Ignore);
1705        plan.scalar_plan_mut().predicate = Some(Predicate::eq(
1706            "tag".to_string(),
1707            Value::Text("alpha".to_string()),
1708        ));
1709        plan.scalar_plan_mut().order = Some(OrderSpec {
1710            fields: vec![OrderTerm::field("tag", OrderDirection::Asc)],
1711        });
1712
1713        let summary = QueryAdmissionSummary::from_plan(QueryAdmissionLane::AdminAdHoc, &plan);
1714
1715        assert_eq!(
1716            summary.residual_filter(),
1717            QueryAdmissionResidualFilter::Predicate
1718        );
1719        assert_eq!(summary.ordering(), QueryAdmissionOrdering::Requested);
1720        assert!(!summary.materialization().materialized_sort());
1721        assert_eq!(summary.materialization().materialized_rows(), None);
1722        assert_eq!(
1723            summary.materialization().row_bound_kind(),
1724            QueryBoundKind::Unavailable
1725        );
1726    }
1727
1728    #[test]
1729    fn plan_summary_carries_grouped_execution_budgets() {
1730        let grouped =
1731            AccessPlannedQuery::new(AccessPath::<Value>::FullScan, MissingRowPolicy::Ignore)
1732                .into_grouped_with_having_expr(
1733                    GroupSpec {
1734                        group_fields: vec![FieldSlot::from_test_slot(0, "tag")],
1735                        aggregates: vec![GroupAggregateSpec {
1736                            kind: AggregateKind::Count,
1737                            input_expr: None,
1738                            filter_expr: None,
1739                            distinct: false,
1740                        }],
1741                        execution: GroupedExecutionConfig::with_hard_limits(12, 4096),
1742                    },
1743                    Some(Expr::Field(FieldId::new("tag"))),
1744                );
1745
1746        let summary =
1747            QueryAdmissionSummary::from_plan(QueryAdmissionLane::DiagnosticExplain, &grouped);
1748        let grouped = summary
1749            .grouped()
1750            .expect("summary should include grouped facts");
1751
1752        assert_eq!(
1753            summary.plan_shape(),
1754            QueryAdmissionPlanShape::GroupedAggregate
1755        );
1756        assert_eq!(grouped.group_field_count(), 1);
1757        assert_eq!(grouped.aggregate_count(), 1);
1758        assert_eq!(grouped.max_groups(), 12);
1759        assert_eq!(grouped.max_group_bytes(), 4096);
1760        assert!(grouped.has_having_filter());
1761        assert_eq!(summary.returned_row_bound(), Some(12));
1762        assert_eq!(
1763            summary.returned_row_bound_kind(),
1764            QueryBoundKind::ConservativeUpperBound
1765        );
1766    }
1767
1768    #[test]
1769    fn plan_summary_reads_delete_window_without_executing_it() {
1770        let mut plan =
1771            AccessPlannedQuery::new(AccessPath::<Value>::FullScan, MissingRowPolicy::Ignore);
1772        plan.scalar_plan_mut().mode = QueryMode::Delete(DeleteSpec::new());
1773        plan.scalar_plan_mut().delete_limit = Some(DeleteLimitSpec {
1774            limit: Some(3),
1775            offset: 1,
1776        });
1777
1778        let summary =
1779            QueryAdmissionSummary::from_plan(QueryAdmissionLane::DiagnosticExplain, &plan);
1780
1781        assert_eq!(summary.plan_shape(), QueryAdmissionPlanShape::Delete);
1782        assert_eq!(summary.limit(), Some(3));
1783        assert_eq!(summary.offset(), Some(1));
1784        assert_eq!(summary.returned_row_bound(), Some(3));
1785    }
1786
1787    #[test]
1788    fn public_read_evaluation_rejects_missing_limit_before_access_shape() {
1789        let policy = public_read_policy();
1790        let summary = summary_for_index_prefix(None, 0);
1791
1792        let evaluated = policy.evaluate(summary);
1793
1794        assert_eq!(evaluated.decision(), QueryAdmissionDecision::Rejected);
1795        assert_eq!(
1796            evaluated.rejection(),
1797            Some(QueryAdmissionRejection::PublicQueryRequiresLimit)
1798        );
1799    }
1800
1801    #[test]
1802    fn public_read_evaluation_rejects_full_scan_even_with_limit() {
1803        let policy = public_read_policy();
1804        let summary = summary_for_path(AccessPath::<Value>::FullScan, Some(5), 0);
1805
1806        let evaluated = policy.evaluate(summary);
1807
1808        assert_eq!(evaluated.decision(), QueryAdmissionDecision::Rejected);
1809        assert_eq!(
1810            evaluated.rejection(),
1811            Some(QueryAdmissionRejection::UnboundedFullScanRejected)
1812        );
1813    }
1814
1815    #[test]
1816    fn public_read_evaluation_admits_indexed_bounded_scalar_read() {
1817        let policy = public_read_policy();
1818        let summary = summary_for_index_prefix(Some(5), 0);
1819
1820        let evaluated = policy.evaluate(summary);
1821
1822        assert_eq!(evaluated.decision(), QueryAdmissionDecision::Admitted);
1823        assert_eq!(evaluated.rejection(), None);
1824    }
1825
1826    #[test]
1827    fn public_read_evaluation_admits_exact_primary_key_read() {
1828        let policy = public_read_policy();
1829        let summary = summary_for_path(
1830            AccessPath::ByKey(Value::Text("primary".to_string())),
1831            Some(1),
1832            0,
1833        );
1834
1835        let evaluated = policy.evaluate(summary);
1836
1837        assert_eq!(evaluated.decision(), QueryAdmissionDecision::Admitted);
1838        assert_eq!(evaluated.scan_bound(), Some(1));
1839    }
1840
1841    #[test]
1842    fn public_read_evaluation_rejects_non_zero_offset() {
1843        let policy = public_read_policy();
1844        let summary = summary_for_index_prefix(Some(5), 1);
1845
1846        let evaluated = policy.evaluate(summary);
1847
1848        assert_eq!(evaluated.decision(), QueryAdmissionDecision::Rejected);
1849        assert_eq!(
1850            evaluated.rejection(),
1851            Some(QueryAdmissionRejection::PublicQueryOffsetRejected)
1852        );
1853    }
1854
1855    #[test]
1856    fn public_read_evaluation_rejects_returned_row_cap_overflow() {
1857        let policy = public_read_policy();
1858        let summary = summary_for_index_prefix(Some(51), 0);
1859
1860        let evaluated = policy.evaluate(summary);
1861
1862        assert_eq!(evaluated.decision(), QueryAdmissionDecision::Rejected);
1863        assert_eq!(
1864            evaluated.rejection(),
1865            Some(QueryAdmissionRejection::ReturnedRowBoundExceedsPolicy)
1866        );
1867    }
1868
1869    #[test]
1870    fn public_read_evaluation_rejects_unresolved_order_materialized_sort() {
1871        let policy = public_read_policy();
1872        let summary = summary_for_index_prefix(Some(5), 0);
1873        let returned_row_bound = summary.returned_row_bound();
1874        let returned_row_bound_kind = summary.returned_row_bound_kind();
1875        let summary = summary.with_materialization(QueryMaterializationSummary::sort(
1876            returned_row_bound,
1877            returned_row_bound_kind,
1878        ));
1879
1880        let evaluated = policy.evaluate(summary);
1881
1882        assert_eq!(evaluated.decision(), QueryAdmissionDecision::Rejected);
1883        assert_eq!(
1884            evaluated.rejection(),
1885            Some(QueryAdmissionRejection::SortRequiresMaterialization)
1886        );
1887    }
1888
1889    #[test]
1890    fn diagnostic_explain_policy_rejects_row_execution() {
1891        let policy = QueryAdmissionPolicy::diagnostic_explain();
1892        let summary = summary_for_index_prefix(Some(5), 0);
1893
1894        let evaluated = policy.evaluate(summary);
1895
1896        assert_eq!(evaluated.decision(), QueryAdmissionDecision::Rejected);
1897        assert_eq!(
1898            evaluated.rejection(),
1899            Some(QueryAdmissionRejection::DiagnosticLaneDoesNotExecute)
1900        );
1901    }
1902
1903    fn public_read_policy() -> QueryAdmissionPolicy {
1904        QueryAdmissionPolicy::public_read(
1905            NonZeroU32::new(50).expect("test public row cap is non-zero"),
1906            NonZeroU32::new(32_768).expect("test public byte cap is non-zero"),
1907        )
1908    }
1909
1910    fn summary_for_index_prefix(limit: Option<u32>, offset: u32) -> QueryAdmissionSummary {
1911        summary_for_path(
1912            AccessPath::IndexPrefix {
1913                index: SemanticIndexAccessContract::model_only_from_generated_index(
1914                    ADMISSION_INDEX,
1915                ),
1916                values: vec![Value::Text("alpha".to_string())],
1917            },
1918            limit,
1919            offset,
1920        )
1921    }
1922
1923    fn summary_for_path(
1924        path: AccessPath<Value>,
1925        limit: Option<u32>,
1926        offset: u32,
1927    ) -> QueryAdmissionSummary {
1928        let mut plan = AccessPlannedQuery::new(path, MissingRowPolicy::Ignore);
1929        plan.scalar_plan_mut().page = Some(PageSpec { limit, offset });
1930
1931        QueryAdmissionSummary::from_plan(QueryAdmissionLane::PublicRead, &plan)
1932    }
1933}