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
976        Self {
977            lane,
978            decision: QueryAdmissionDecision::Admitted,
979            plan_shape: plan_shape(plan),
980            selected_access: access.kind,
981            selected_index: access.selected_index,
982            limit,
983            offset: Some(offset),
984            scan_bound: access.exact_scan_bound,
985            scan_bound_kind,
986            returned_row_bound,
987            returned_row_bound_kind,
988            response_byte_bound: None,
989            response_byte_bound_kind: QueryBoundKind::Unavailable,
990            residual_filter: admission_residual_filter(plan.residual_filter_shape()),
991            ordering: admission_ordering(plan),
992            grouped,
993            materialization: QueryMaterializationSummary::none(),
994            rejection: None,
995        }
996    }
997
998    const fn admit(mut self) -> Self {
999        self.decision = QueryAdmissionDecision::Admitted;
1000        self.rejection = None;
1001        self
1002    }
1003
1004    const fn reject(mut self, rejection: QueryAdmissionRejection) -> Self {
1005        self.decision = QueryAdmissionDecision::Rejected;
1006        self.rejection = Some(rejection);
1007        self
1008    }
1009
1010    /// Return the admission lane.
1011    #[must_use]
1012    pub const fn lane(&self) -> QueryAdmissionLane {
1013        self.lane
1014    }
1015
1016    /// Return the final decision.
1017    #[must_use]
1018    pub const fn decision(&self) -> QueryAdmissionDecision {
1019        self.decision
1020    }
1021
1022    /// Return the scalar/grouped statement shape.
1023    #[must_use]
1024    pub const fn plan_shape(&self) -> QueryAdmissionPlanShape {
1025        self.plan_shape
1026    }
1027
1028    /// Return the selected access class.
1029    #[must_use]
1030    pub const fn selected_access(&self) -> QueryAdmissionAccessKind {
1031        self.selected_access
1032    }
1033
1034    /// Return the selected index name, if one exists.
1035    #[must_use]
1036    pub fn selected_index(&self) -> Option<&str> {
1037        self.selected_index.as_deref()
1038    }
1039
1040    /// Return the caller-visible LIMIT, if present.
1041    #[must_use]
1042    pub const fn limit(&self) -> Option<u32> {
1043        self.limit
1044    }
1045
1046    /// Return the caller-visible OFFSET, if present.
1047    #[must_use]
1048    pub const fn offset(&self) -> Option<u32> {
1049        self.offset
1050    }
1051
1052    /// Return the scan bound, if known.
1053    #[must_use]
1054    pub const fn scan_bound(&self) -> Option<u64> {
1055        self.scan_bound
1056    }
1057
1058    /// Return the quality of the scan bound.
1059    #[must_use]
1060    pub const fn scan_bound_kind(&self) -> QueryBoundKind {
1061        self.scan_bound_kind
1062    }
1063
1064    /// Return the returned-row bound, if known.
1065    #[must_use]
1066    pub const fn returned_row_bound(&self) -> Option<u32> {
1067        self.returned_row_bound
1068    }
1069
1070    /// Return the quality of the returned-row bound.
1071    #[must_use]
1072    pub const fn returned_row_bound_kind(&self) -> QueryBoundKind {
1073        self.returned_row_bound_kind
1074    }
1075
1076    /// Return the response-byte bound, if known.
1077    #[must_use]
1078    pub const fn response_byte_bound(&self) -> Option<u32> {
1079        self.response_byte_bound
1080    }
1081
1082    /// Return the quality of the response-byte bound.
1083    #[must_use]
1084    pub const fn response_byte_bound_kind(&self) -> QueryBoundKind {
1085        self.response_byte_bound_kind
1086    }
1087
1088    /// Return post-access residual filter facts.
1089    #[must_use]
1090    pub const fn residual_filter(&self) -> QueryAdmissionResidualFilter {
1091        self.residual_filter
1092    }
1093
1094    /// Return ORDER BY facts.
1095    #[must_use]
1096    pub const fn ordering(&self) -> QueryAdmissionOrdering {
1097        self.ordering
1098    }
1099
1100    /// Return grouped query facts, if this is a grouped plan.
1101    #[must_use]
1102    pub const fn grouped(&self) -> Option<QueryAdmissionGroupedSummary> {
1103        self.grouped
1104    }
1105
1106    /// Return materialization facts.
1107    #[must_use]
1108    pub const fn materialization(&self) -> QueryMaterializationSummary {
1109        self.materialization
1110    }
1111
1112    /// Return the rejection reason, when the decision is rejected.
1113    #[must_use]
1114    pub const fn rejection(&self) -> Option<QueryAdmissionRejection> {
1115        self.rejection
1116    }
1117
1118    /// Render this summary as a stable top-level verbose EXPLAIN block.
1119    #[must_use]
1120    pub(in crate::db) fn render_text_block(&self) -> String {
1121        let mut out = String::from("admission:");
1122        push_text_field(&mut out, "lane", self.lane().as_str());
1123        push_text_field(&mut out, "decision", self.decision().as_str());
1124        push_text_field(
1125            &mut out,
1126            "reason",
1127            self.rejection()
1128                .map_or("none", QueryAdmissionRejection::as_str),
1129        );
1130        push_text_field(&mut out, "plan_shape", self.plan_shape().as_str());
1131        push_text_field(&mut out, "selected_access", self.selected_access().as_str());
1132        push_text_field(
1133            &mut out,
1134            "selected_index",
1135            self.selected_index().unwrap_or("none"),
1136        );
1137        push_text_option_u32(&mut out, "limit", self.limit());
1138        push_text_option_u32(&mut out, "offset", self.offset());
1139        push_text_option_u64(&mut out, "scan_bound", self.scan_bound());
1140        push_text_field(&mut out, "scan_bound_kind", self.scan_bound_kind().as_str());
1141        push_text_option_u32(&mut out, "returned_row_bound", self.returned_row_bound());
1142        push_text_field(
1143            &mut out,
1144            "returned_row_bound_kind",
1145            self.returned_row_bound_kind().as_str(),
1146        );
1147        push_text_option_u32(&mut out, "response_byte_bound", self.response_byte_bound());
1148        push_text_field(
1149            &mut out,
1150            "response_byte_bound_kind",
1151            self.response_byte_bound_kind().as_str(),
1152        );
1153        push_text_field(&mut out, "residual_filter", self.residual_filter().as_str());
1154        push_text_field(&mut out, "ordering", self.ordering().as_str());
1155        push_text_bool(
1156            &mut out,
1157            "materialized_sort",
1158            self.materialization().materialized_sort(),
1159        );
1160        push_text_option_u32(
1161            &mut out,
1162            "materialized_rows",
1163            self.materialization().materialized_rows(),
1164        );
1165        push_text_field(
1166            &mut out,
1167            "materialized_row_bound_kind",
1168            self.materialization().row_bound_kind().as_str(),
1169        );
1170
1171        if let Some(grouped) = self.grouped() {
1172            push_text_bool(&mut out, "grouped", true);
1173            push_text_u64(
1174                &mut out,
1175                "group_field_count",
1176                u64::from(grouped.group_field_count()),
1177            );
1178            push_text_u64(
1179                &mut out,
1180                "aggregate_count",
1181                u64::from(grouped.aggregate_count()),
1182            );
1183            push_text_u64(&mut out, "max_groups", grouped.max_groups());
1184            push_text_u64(&mut out, "max_group_bytes", grouped.max_group_bytes());
1185            push_text_bool(&mut out, "having_filter", grouped.has_having_filter());
1186        } else {
1187            push_text_bool(&mut out, "grouped", false);
1188        }
1189
1190        out
1191    }
1192}
1193
1194fn push_text_field(out: &mut String, key: &str, value: &str) {
1195    out.push('\n');
1196    out.push_str("  ");
1197    out.push_str(key);
1198    out.push('=');
1199    out.push_str(value);
1200}
1201
1202fn push_text_bool(out: &mut String, key: &str, value: bool) {
1203    push_text_field(out, key, if value { "true" } else { "false" });
1204}
1205
1206fn push_text_u64(out: &mut String, key: &str, value: u64) {
1207    out.push('\n');
1208    out.push_str("  ");
1209    out.push_str(key);
1210    out.push('=');
1211    let _ = write!(out, "{value}");
1212}
1213
1214fn push_text_option_u32(out: &mut String, key: &str, value: Option<u32>) {
1215    match value {
1216        Some(value) => push_text_u64(out, key, u64::from(value)),
1217        None => push_text_field(out, key, "none"),
1218    }
1219}
1220
1221fn push_text_option_u64(out: &mut String, key: &str, value: Option<u64>) {
1222    match value {
1223        Some(value) => push_text_u64(out, key, value),
1224        None => push_text_field(out, key, "none"),
1225    }
1226}
1227
1228// Keep the staged extractor live before admission enforcement calls it directly.
1229const _: fn(QueryAdmissionLane, &AccessPlannedQuery) -> QueryAdmissionSummary =
1230    QueryAdmissionSummary::from_plan;
1231
1232const fn access_satisfies_index_requirement(
1233    kind: QueryAdmissionAccessKind,
1234    scan_bound: Option<u64>,
1235) -> bool {
1236    kind.is_secondary_index()
1237        || matches!(
1238            (kind, scan_bound),
1239            (
1240                QueryAdmissionAccessKind::ByKey | QueryAdmissionAccessKind::ByKeys,
1241                Some(_)
1242            )
1243        )
1244}
1245
1246struct AdmissionAccessProjection;
1247
1248#[derive(Clone, Debug, Eq, PartialEq)]
1249struct AdmissionAccessSummary {
1250    kind: QueryAdmissionAccessKind,
1251    selected_index: Option<String>,
1252    exact_scan_bound: Option<u64>,
1253}
1254
1255impl AdmissionAccessSummary {
1256    const fn non_index(kind: QueryAdmissionAccessKind, exact_scan_bound: Option<u64>) -> Self {
1257        Self {
1258            kind,
1259            selected_index: None,
1260            exact_scan_bound,
1261        }
1262    }
1263
1264    fn secondary_index(kind: QueryAdmissionAccessKind, index_name: &str) -> Self {
1265        Self {
1266            kind,
1267            selected_index: Some(index_name.to_string()),
1268            exact_scan_bound: None,
1269        }
1270    }
1271
1272    const fn composite(kind: QueryAdmissionAccessKind) -> Self {
1273        Self {
1274            kind,
1275            selected_index: None,
1276            exact_scan_bound: None,
1277        }
1278    }
1279
1280    const fn scan_bound_kind(&self) -> QueryBoundKind {
1281        if self.exact_scan_bound.is_some() {
1282            QueryBoundKind::Exact
1283        } else {
1284            QueryBoundKind::Unavailable
1285        }
1286    }
1287}
1288
1289impl AccessPlanProjection<Value> for AdmissionAccessProjection {
1290    type Output = AdmissionAccessSummary;
1291
1292    fn by_key(&mut self, _key: &Value) -> Self::Output {
1293        AdmissionAccessSummary::non_index(QueryAdmissionAccessKind::ByKey, Some(1))
1294    }
1295
1296    fn by_keys(&mut self, keys: &[Value]) -> Self::Output {
1297        AdmissionAccessSummary::non_index(
1298            QueryAdmissionAccessKind::ByKeys,
1299            Some(u64::try_from(keys.len()).unwrap_or(u64::MAX)),
1300        )
1301    }
1302
1303    fn key_range(&mut self, _start: &Value, _end: &Value) -> Self::Output {
1304        AdmissionAccessSummary::non_index(QueryAdmissionAccessKind::KeyRange, None)
1305    }
1306
1307    fn index_prefix(
1308        &mut self,
1309        index_name: &str,
1310        _index_fields: &[String],
1311        _prefix_len: usize,
1312        _values: &[Value],
1313    ) -> Self::Output {
1314        AdmissionAccessSummary::secondary_index(QueryAdmissionAccessKind::IndexPrefix, index_name)
1315    }
1316
1317    fn index_multi_lookup(
1318        &mut self,
1319        index_name: &str,
1320        _index_fields: &[String],
1321        _values: &[Value],
1322    ) -> Self::Output {
1323        AdmissionAccessSummary::secondary_index(
1324            QueryAdmissionAccessKind::IndexMultiLookup,
1325            index_name,
1326        )
1327    }
1328
1329    fn index_branch_set(
1330        &mut self,
1331        index_name: &str,
1332        _index_fields: &[String],
1333        _fixed_values: &[Value],
1334        _branch_values: &[Value],
1335        _ordered_suffix: IndexBranchSetOrderedSuffix,
1336    ) -> Self::Output {
1337        AdmissionAccessSummary::secondary_index(
1338            QueryAdmissionAccessKind::IndexBranchSet,
1339            index_name,
1340        )
1341    }
1342
1343    fn index_range(
1344        &mut self,
1345        index_name: &str,
1346        _index_fields: &[String],
1347        _prefix_len: usize,
1348        _prefix: &[Value],
1349        _lower: &Bound<Value>,
1350        _upper: &Bound<Value>,
1351    ) -> Self::Output {
1352        AdmissionAccessSummary::secondary_index(QueryAdmissionAccessKind::IndexRange, index_name)
1353    }
1354
1355    fn full_scan(&mut self) -> Self::Output {
1356        AdmissionAccessSummary::non_index(QueryAdmissionAccessKind::FullScan, None)
1357    }
1358
1359    fn union(&mut self, _children: Vec<Self::Output>) -> Self::Output {
1360        AdmissionAccessSummary::composite(QueryAdmissionAccessKind::Union)
1361    }
1362
1363    fn intersection(&mut self, _children: Vec<Self::Output>) -> Self::Output {
1364        AdmissionAccessSummary::composite(QueryAdmissionAccessKind::Intersection)
1365    }
1366}
1367
1368fn summarize_access_plan(plan: &AccessPlannedQuery) -> AdmissionAccessSummary {
1369    project_access_plan(&plan.access, &mut AdmissionAccessProjection)
1370}
1371
1372fn summarize_grouped_plan(plan: &GroupPlan) -> QueryAdmissionGroupedSummary {
1373    QueryAdmissionGroupedSummary::new(
1374        u32::try_from(plan.group.group_fields.len()).unwrap_or(u32::MAX),
1375        u32::try_from(plan.group.aggregates.len()).unwrap_or(u32::MAX),
1376        plan.group.execution.max_groups(),
1377        plan.group.execution.max_group_bytes(),
1378        plan.having_expr.is_some(),
1379    )
1380}
1381
1382const fn scalar_limit_and_offset(plan: &ScalarPlan) -> (Option<u32>, u32) {
1383    match plan.mode {
1384        QueryMode::Load(load) => match &plan.page {
1385            Some(page) => (page.limit, page.offset),
1386            None => (load.limit(), load.offset()),
1387        },
1388        QueryMode::Delete(delete) => match plan.delete_limit {
1389            Some(delete_limit) => (delete_limit.limit, delete_limit.offset),
1390            None => (delete.limit(), delete.offset()),
1391        },
1392    }
1393}
1394
1395fn returned_row_bound_from_plan(
1396    limit: Option<u32>,
1397    grouped: Option<QueryAdmissionGroupedSummary>,
1398) -> (Option<u32>, QueryBoundKind) {
1399    if let Some(limit) = limit {
1400        return (Some(limit), QueryBoundKind::EnforcedRuntimeCap);
1401    }
1402
1403    let Some(grouped) = grouped else {
1404        return (None, QueryBoundKind::Unavailable);
1405    };
1406    if grouped.max_groups() == u64::MAX {
1407        return (None, QueryBoundKind::Unavailable);
1408    }
1409
1410    (
1411        Some(u32::try_from(grouped.max_groups()).unwrap_or(u32::MAX)),
1412        QueryBoundKind::ConservativeUpperBound,
1413    )
1414}
1415
1416const fn admission_residual_filter(shape: ResidualFilterShape) -> QueryAdmissionResidualFilter {
1417    match shape {
1418        ResidualFilterShape::Absent => QueryAdmissionResidualFilter::Absent,
1419        ResidualFilterShape::Predicate => QueryAdmissionResidualFilter::Predicate,
1420        ResidualFilterShape::Expression => QueryAdmissionResidualFilter::Expression,
1421        ResidualFilterShape::ExpressionAndPredicate => {
1422            QueryAdmissionResidualFilter::ExpressionAndPredicate
1423        }
1424    }
1425}
1426
1427fn admission_ordering(plan: &AccessPlannedQuery) -> QueryAdmissionOrdering {
1428    if plan.scalar_plan().order.is_none() {
1429        return QueryAdmissionOrdering::None;
1430    }
1431
1432    if plan.resolved_order().is_some() {
1433        QueryAdmissionOrdering::Resolved
1434    } else {
1435        QueryAdmissionOrdering::Requested
1436    }
1437}
1438
1439const fn plan_shape(plan: &AccessPlannedQuery) -> QueryAdmissionPlanShape {
1440    if plan.grouped_plan().is_some() {
1441        return QueryAdmissionPlanShape::GroupedAggregate;
1442    }
1443
1444    match plan.scalar_plan().mode {
1445        QueryMode::Load(_) => QueryAdmissionPlanShape::ScalarRead,
1446        QueryMode::Delete(_) => QueryAdmissionPlanShape::Delete,
1447    }
1448}
1449
1450#[cfg(test)]
1451mod tests {
1452    use std::num::{NonZeroU32, NonZeroU64};
1453
1454    use crate::{
1455        db::{
1456            access::{AccessPath, SemanticIndexAccessContract},
1457            predicate::{MissingRowPolicy, Predicate},
1458            query::plan::{
1459                AccessPlannedQuery, AggregateKind, DeleteLimitSpec, DeleteSpec, FieldSlot,
1460                GroupAggregateSpec, GroupSpec, GroupedExecutionConfig, OrderDirection, OrderSpec,
1461                OrderTerm, PageSpec, QueryMode,
1462                expr::{Expr, FieldId},
1463            },
1464        },
1465        model::index::IndexModel,
1466        value::Value,
1467    };
1468
1469    use super::{
1470        GroupedAdmissionPolicy, QueryAdmissionAccessKind, QueryAdmissionDecision,
1471        QueryAdmissionLane, QueryAdmissionOrdering, QueryAdmissionPlanShape, QueryAdmissionPolicy,
1472        QueryAdmissionRejection, QueryAdmissionResidualFilter, QueryAdmissionSummary,
1473        QueryBoundKind,
1474    };
1475
1476    const ADMISSION_INDEX_FIELDS: [&str; 1] = ["tag"];
1477    const ADMISSION_INDEX: IndexModel = IndexModel::generated(
1478        "admission::tag",
1479        "admission::tag_store",
1480        &ADMISSION_INDEX_FIELDS,
1481        false,
1482    );
1483
1484    #[test]
1485    fn public_read_policy_has_safe_finite_defaults() {
1486        let max_rows = NonZeroU32::new(50).expect("test max rows is non-zero");
1487        let max_bytes = NonZeroU32::new(32_768).expect("test max bytes is non-zero");
1488        let policy = QueryAdmissionPolicy::public_read(max_rows, max_bytes);
1489
1490        assert_eq!(policy.lane(), QueryAdmissionLane::PublicRead);
1491        assert!(policy.require_limit());
1492        assert!(policy.require_index());
1493        assert!(policy.reject_non_zero_offset());
1494        assert!(!policy.allow_full_scan());
1495        assert!(!policy.allow_materialized_sort());
1496        assert_eq!(policy.max_returned_rows(), Some(max_rows));
1497        assert_eq!(policy.max_response_bytes(), Some(max_bytes));
1498        assert!(policy.public_caps_are_finite());
1499        assert!(!policy.grouped().has_hard_limits());
1500    }
1501
1502    #[test]
1503    fn admin_policy_is_broader_but_still_budgeted() {
1504        let max_rows = NonZeroU32::new(100).expect("test max rows is non-zero");
1505        let max_scanned = NonZeroU64::new(1_000).expect("test scan cap is non-zero");
1506        let max_bytes = NonZeroU32::new(65_536).expect("test max bytes is non-zero");
1507        let policy = QueryAdmissionPolicy::admin_ad_hoc(max_rows, max_scanned, max_bytes);
1508
1509        assert_eq!(policy.lane(), QueryAdmissionLane::AdminAdHoc);
1510        assert!(!policy.require_limit());
1511        assert!(!policy.require_index());
1512        assert!(policy.allow_full_scan());
1513        assert!(policy.allow_materialized_sort());
1514        assert_eq!(policy.max_scanned_rows(), Some(max_scanned));
1515        assert_eq!(policy.max_materialized_rows(), Some(max_rows));
1516    }
1517
1518    #[test]
1519    fn diagnostic_explain_lane_does_not_execute_rows() {
1520        let policy = QueryAdmissionPolicy::diagnostic_explain();
1521
1522        assert_eq!(policy.lane().as_str(), "diagnostic_explain");
1523        assert!(!policy.lane().executes_rows());
1524    }
1525
1526    #[test]
1527    fn grouped_policy_requires_group_and_memory_budgets() {
1528        let max_groups = NonZeroU32::new(8).expect("test group cap is non-zero");
1529        let max_bytes = NonZeroU32::new(4096).expect("test byte cap is non-zero");
1530        let policy = GroupedAdmissionPolicy::bounded(max_groups, max_bytes, None);
1531
1532        assert!(policy.has_hard_limits());
1533        assert_eq!(policy.max_groups(), Some(max_groups));
1534        assert_eq!(policy.max_group_bytes(), Some(max_bytes));
1535    }
1536
1537    #[test]
1538    fn only_proven_or_enforced_bounds_admit_public_reads() {
1539        assert!(QueryBoundKind::Exact.admits_public_read());
1540        assert!(QueryBoundKind::ConservativeUpperBound.admits_public_read());
1541        assert!(QueryBoundKind::EnforcedRuntimeCap.admits_public_read());
1542        assert!(!QueryBoundKind::EstimateOnly.admits_public_read());
1543        assert!(!QueryBoundKind::Unavailable.admits_public_read());
1544    }
1545
1546    #[test]
1547    fn access_kind_classifies_secondary_indexes_and_full_scans() {
1548        assert!(QueryAdmissionAccessKind::IndexPrefix.is_secondary_index());
1549        assert!(QueryAdmissionAccessKind::FullScan.is_full_scan());
1550        assert!(!QueryAdmissionAccessKind::ByKey.is_secondary_index());
1551    }
1552
1553    #[test]
1554    fn rejection_maps_to_stable_diagnostic() {
1555        let rejection = QueryAdmissionRejection::PublicQueryRequiresLimit;
1556        let diagnostic = rejection.diagnostic();
1557
1558        assert_eq!(
1559            rejection.error_code(),
1560            icydb_diagnostic_code::ErrorCode::QUERY_READ_PUBLIC_REQUIRES_LIMIT
1561        );
1562        assert_eq!(
1563            diagnostic.code(),
1564            icydb_diagnostic_code::DiagnosticCode::QueryReadAdmission
1565        );
1566    }
1567
1568    #[test]
1569    fn summaries_keep_decision_and_rejection_aligned() {
1570        let admitted = QueryAdmissionSummary::admitted(
1571            QueryAdmissionLane::PublicRead,
1572            QueryAdmissionAccessKind::ByKey,
1573        );
1574        let rejected = QueryAdmissionSummary::rejected(
1575            QueryAdmissionLane::PublicRead,
1576            QueryAdmissionAccessKind::FullScan,
1577            QueryAdmissionRejection::UnboundedFullScanRejected,
1578        );
1579
1580        assert_eq!(admitted.decision(), QueryAdmissionDecision::Admitted);
1581        assert_eq!(admitted.rejection(), None);
1582        assert_eq!(rejected.decision(), QueryAdmissionDecision::Rejected);
1583        assert_eq!(
1584            rejected.rejection(),
1585            Some(QueryAdmissionRejection::UnboundedFullScanRejected)
1586        );
1587    }
1588
1589    #[test]
1590    fn admission_summary_renders_stable_verbose_explain_block() {
1591        let summary = QueryAdmissionSummary::rejected(
1592            QueryAdmissionLane::PublicRead,
1593            QueryAdmissionAccessKind::FullScan,
1594            QueryAdmissionRejection::UnboundedFullScanRejected,
1595        );
1596
1597        let rendered = summary.render_text_block();
1598
1599        assert!(
1600            rendered.starts_with("admission:\n  lane=public_read\n  decision=rejected"),
1601            "admission block should start with stable lane and decision fields: {rendered}",
1602        );
1603        assert!(
1604            rendered.contains("\n  reason=unbounded_full_scan_rejected"),
1605            "admission block should include a stable rejection reason: {rendered}",
1606        );
1607        assert!(
1608            rendered.contains("\n  selected_access=full_scan"),
1609            "admission block should include the selected access class: {rendered}",
1610        );
1611        assert!(
1612            rendered.contains("\n  grouped=false"),
1613            "admission block should include grouped classification: {rendered}",
1614        );
1615    }
1616
1617    #[test]
1618    fn plan_summary_classifies_full_scan_without_overclaiming_bounds() {
1619        let plan = AccessPlannedQuery::new(AccessPath::<Value>::FullScan, MissingRowPolicy::Ignore);
1620
1621        let summary = QueryAdmissionSummary::from_plan(QueryAdmissionLane::PublicRead, &plan);
1622
1623        assert_eq!(summary.plan_shape(), QueryAdmissionPlanShape::ScalarRead);
1624        assert_eq!(
1625            summary.selected_access(),
1626            QueryAdmissionAccessKind::FullScan
1627        );
1628        assert_eq!(summary.selected_index(), None);
1629        assert_eq!(summary.limit(), None);
1630        assert_eq!(summary.offset(), Some(0));
1631        assert_eq!(summary.scan_bound(), None);
1632        assert_eq!(summary.scan_bound_kind(), QueryBoundKind::Unavailable);
1633        assert_eq!(summary.returned_row_bound(), None);
1634        assert_eq!(
1635            summary.returned_row_bound_kind(),
1636            QueryBoundKind::Unavailable
1637        );
1638        assert_eq!(
1639            summary.residual_filter(),
1640            QueryAdmissionResidualFilter::Absent
1641        );
1642        assert_eq!(summary.ordering(), QueryAdmissionOrdering::None);
1643    }
1644
1645    #[test]
1646    fn plan_summary_uses_point_lookup_and_limit_as_proven_bounds() {
1647        let mut plan =
1648            AccessPlannedQuery::new(AccessPath::ByKey(Value::Nat64(7)), MissingRowPolicy::Ignore);
1649        plan.scalar_plan_mut().page = Some(PageSpec {
1650            limit: Some(5),
1651            offset: 2,
1652        });
1653
1654        let summary = QueryAdmissionSummary::from_plan(QueryAdmissionLane::PublicRead, &plan);
1655
1656        assert_eq!(summary.selected_access(), QueryAdmissionAccessKind::ByKey);
1657        assert_eq!(summary.limit(), Some(5));
1658        assert_eq!(summary.offset(), Some(2));
1659        assert_eq!(summary.scan_bound(), Some(1));
1660        assert_eq!(summary.scan_bound_kind(), QueryBoundKind::Exact);
1661        assert_eq!(summary.returned_row_bound(), Some(5));
1662        assert_eq!(
1663            summary.returned_row_bound_kind(),
1664            QueryBoundKind::EnforcedRuntimeCap
1665        );
1666    }
1667
1668    #[test]
1669    fn plan_summary_preserves_selected_index_identity() {
1670        let plan = AccessPlannedQuery::new(
1671            AccessPath::IndexPrefix {
1672                index: SemanticIndexAccessContract::model_only_from_generated_index(
1673                    ADMISSION_INDEX,
1674                ),
1675                values: vec![Value::Text("alpha".to_string())],
1676            },
1677            MissingRowPolicy::Ignore,
1678        );
1679
1680        let summary = QueryAdmissionSummary::from_plan(QueryAdmissionLane::PublicRead, &plan);
1681
1682        assert_eq!(
1683            summary.selected_access(),
1684            QueryAdmissionAccessKind::IndexPrefix
1685        );
1686        assert_eq!(summary.selected_index(), Some("admission::tag"));
1687        assert_eq!(summary.scan_bound(), None);
1688        assert_eq!(summary.scan_bound_kind(), QueryBoundKind::Unavailable);
1689    }
1690
1691    #[test]
1692    fn plan_summary_classifies_residual_and_requested_ordering() {
1693        let mut plan =
1694            AccessPlannedQuery::new(AccessPath::<Value>::FullScan, MissingRowPolicy::Ignore);
1695        plan.scalar_plan_mut().predicate = Some(Predicate::eq(
1696            "tag".to_string(),
1697            Value::Text("alpha".to_string()),
1698        ));
1699        plan.scalar_plan_mut().order = Some(OrderSpec {
1700            fields: vec![OrderTerm::field("tag", OrderDirection::Asc)],
1701        });
1702
1703        let summary = QueryAdmissionSummary::from_plan(QueryAdmissionLane::AdminAdHoc, &plan);
1704
1705        assert_eq!(
1706            summary.residual_filter(),
1707            QueryAdmissionResidualFilter::Predicate
1708        );
1709        assert_eq!(summary.ordering(), QueryAdmissionOrdering::Requested);
1710    }
1711
1712    #[test]
1713    fn plan_summary_carries_grouped_execution_budgets() {
1714        let grouped =
1715            AccessPlannedQuery::new(AccessPath::<Value>::FullScan, MissingRowPolicy::Ignore)
1716                .into_grouped_with_having_expr(
1717                    GroupSpec {
1718                        group_fields: vec![FieldSlot::from_test_slot(0, "tag")],
1719                        aggregates: vec![GroupAggregateSpec {
1720                            kind: AggregateKind::Count,
1721                            input_expr: None,
1722                            filter_expr: None,
1723                            distinct: false,
1724                        }],
1725                        execution: GroupedExecutionConfig::with_hard_limits(12, 4096),
1726                    },
1727                    Some(Expr::Field(FieldId::new("tag"))),
1728                );
1729
1730        let summary =
1731            QueryAdmissionSummary::from_plan(QueryAdmissionLane::DiagnosticExplain, &grouped);
1732        let grouped = summary
1733            .grouped()
1734            .expect("summary should include grouped facts");
1735
1736        assert_eq!(
1737            summary.plan_shape(),
1738            QueryAdmissionPlanShape::GroupedAggregate
1739        );
1740        assert_eq!(grouped.group_field_count(), 1);
1741        assert_eq!(grouped.aggregate_count(), 1);
1742        assert_eq!(grouped.max_groups(), 12);
1743        assert_eq!(grouped.max_group_bytes(), 4096);
1744        assert!(grouped.has_having_filter());
1745        assert_eq!(summary.returned_row_bound(), Some(12));
1746        assert_eq!(
1747            summary.returned_row_bound_kind(),
1748            QueryBoundKind::ConservativeUpperBound
1749        );
1750    }
1751
1752    #[test]
1753    fn plan_summary_reads_delete_window_without_executing_it() {
1754        let mut plan =
1755            AccessPlannedQuery::new(AccessPath::<Value>::FullScan, MissingRowPolicy::Ignore);
1756        plan.scalar_plan_mut().mode = QueryMode::Delete(DeleteSpec::new());
1757        plan.scalar_plan_mut().delete_limit = Some(DeleteLimitSpec {
1758            limit: Some(3),
1759            offset: 1,
1760        });
1761
1762        let summary =
1763            QueryAdmissionSummary::from_plan(QueryAdmissionLane::DiagnosticExplain, &plan);
1764
1765        assert_eq!(summary.plan_shape(), QueryAdmissionPlanShape::Delete);
1766        assert_eq!(summary.limit(), Some(3));
1767        assert_eq!(summary.offset(), Some(1));
1768        assert_eq!(summary.returned_row_bound(), Some(3));
1769    }
1770
1771    #[test]
1772    fn public_read_evaluation_rejects_missing_limit_before_access_shape() {
1773        let policy = public_read_policy();
1774        let summary = summary_for_index_prefix(None, 0);
1775
1776        let evaluated = policy.evaluate(summary);
1777
1778        assert_eq!(evaluated.decision(), QueryAdmissionDecision::Rejected);
1779        assert_eq!(
1780            evaluated.rejection(),
1781            Some(QueryAdmissionRejection::PublicQueryRequiresLimit)
1782        );
1783    }
1784
1785    #[test]
1786    fn public_read_evaluation_rejects_full_scan_even_with_limit() {
1787        let policy = public_read_policy();
1788        let summary = summary_for_path(AccessPath::<Value>::FullScan, Some(5), 0);
1789
1790        let evaluated = policy.evaluate(summary);
1791
1792        assert_eq!(evaluated.decision(), QueryAdmissionDecision::Rejected);
1793        assert_eq!(
1794            evaluated.rejection(),
1795            Some(QueryAdmissionRejection::UnboundedFullScanRejected)
1796        );
1797    }
1798
1799    #[test]
1800    fn public_read_evaluation_admits_indexed_bounded_scalar_read() {
1801        let policy = public_read_policy();
1802        let summary = summary_for_index_prefix(Some(5), 0);
1803
1804        let evaluated = policy.evaluate(summary);
1805
1806        assert_eq!(evaluated.decision(), QueryAdmissionDecision::Admitted);
1807        assert_eq!(evaluated.rejection(), None);
1808    }
1809
1810    #[test]
1811    fn public_read_evaluation_admits_exact_primary_key_read() {
1812        let policy = public_read_policy();
1813        let summary = summary_for_path(
1814            AccessPath::ByKey(Value::Text("primary".to_string())),
1815            Some(1),
1816            0,
1817        );
1818
1819        let evaluated = policy.evaluate(summary);
1820
1821        assert_eq!(evaluated.decision(), QueryAdmissionDecision::Admitted);
1822        assert_eq!(evaluated.scan_bound(), Some(1));
1823    }
1824
1825    #[test]
1826    fn public_read_evaluation_rejects_non_zero_offset() {
1827        let policy = public_read_policy();
1828        let summary = summary_for_index_prefix(Some(5), 1);
1829
1830        let evaluated = policy.evaluate(summary);
1831
1832        assert_eq!(evaluated.decision(), QueryAdmissionDecision::Rejected);
1833        assert_eq!(
1834            evaluated.rejection(),
1835            Some(QueryAdmissionRejection::PublicQueryOffsetRejected)
1836        );
1837    }
1838
1839    #[test]
1840    fn public_read_evaluation_rejects_returned_row_cap_overflow() {
1841        let policy = public_read_policy();
1842        let summary = summary_for_index_prefix(Some(51), 0);
1843
1844        let evaluated = policy.evaluate(summary);
1845
1846        assert_eq!(evaluated.decision(), QueryAdmissionDecision::Rejected);
1847        assert_eq!(
1848            evaluated.rejection(),
1849            Some(QueryAdmissionRejection::ReturnedRowBoundExceedsPolicy)
1850        );
1851    }
1852
1853    #[test]
1854    fn diagnostic_explain_policy_rejects_row_execution() {
1855        let policy = QueryAdmissionPolicy::diagnostic_explain();
1856        let summary = summary_for_index_prefix(Some(5), 0);
1857
1858        let evaluated = policy.evaluate(summary);
1859
1860        assert_eq!(evaluated.decision(), QueryAdmissionDecision::Rejected);
1861        assert_eq!(
1862            evaluated.rejection(),
1863            Some(QueryAdmissionRejection::DiagnosticLaneDoesNotExecute)
1864        );
1865    }
1866
1867    fn public_read_policy() -> QueryAdmissionPolicy {
1868        QueryAdmissionPolicy::public_read(
1869            NonZeroU32::new(50).expect("test public row cap is non-zero"),
1870            NonZeroU32::new(32_768).expect("test public byte cap is non-zero"),
1871        )
1872    }
1873
1874    fn summary_for_index_prefix(limit: Option<u32>, offset: u32) -> QueryAdmissionSummary {
1875        summary_for_path(
1876            AccessPath::IndexPrefix {
1877                index: SemanticIndexAccessContract::model_only_from_generated_index(
1878                    ADMISSION_INDEX,
1879                ),
1880                values: vec![Value::Text("alpha".to_string())],
1881            },
1882            limit,
1883            offset,
1884        )
1885    }
1886
1887    fn summary_for_path(
1888        path: AccessPath<Value>,
1889        limit: Option<u32>,
1890        offset: u32,
1891    ) -> QueryAdmissionSummary {
1892        let mut plan = AccessPlannedQuery::new(path, MissingRowPolicy::Ignore);
1893        plan.scalar_plan_mut().page = Some(PageSpec { limit, offset });
1894
1895        QueryAdmissionSummary::from_plan(QueryAdmissionLane::PublicRead, &plan)
1896    }
1897}