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