velesdb_core/database/gated_search.rs
1//! Governed (gated) read primitives at the [`Database`] facade.
2//!
3//! Search entry points that do not build a `VelesQL` `Query` — REST vector /
4//! text / hybrid search, and memory recall — historically bypassed the
5//! control-plane read gate because [`Database::get_vector_collection`] hands
6//! back a detached [`VectorCollection`](crate::VectorCollection) with no
7//! observer reference. These methods restore governance for those paths: they
8//! consult the observer via [`Database::read_gate_raw`] (the same resolver the
9//! `VelesQL` gate uses), then delegate to the collection search leaf, applying any
10//! observer-supplied scope narrowing.
11//!
12//! When no observer is registered the gate is a single `Option` check and the
13//! search runs exactly as the ungated path did (zero-overhead contract).
14
15use crate::collection::VectorCollection;
16use crate::filter::{Condition, Filter};
17use crate::observer::QueryOperationKind;
18use crate::point::SearchResult;
19use crate::{Error, Result};
20
21use super::query_engine::RawGateOutcome;
22use super::Database;
23
24/// A non-VelesQL read routed through the control-plane gate.
25///
26/// Each variant maps to a [`VectorCollection`](crate::VectorCollection) search
27/// leaf and to a [`QueryOperationKind`] the observer sees. Observer-supplied
28/// scope filters are AND-composed with any caller filter before execution, so
29/// narrowing can only shrink the result set, never widen it.
30#[derive(Debug, Clone, Copy)]
31pub enum GatedRead<'a> {
32 /// Dense kNN similarity search.
33 ///
34 /// `ef` / `quality` tune recall via the engine's dedicated entry points.
35 /// They are honoured only when no filter is in effect: when a caller filter
36 /// or an observer scope filter is present the search routes through the
37 /// filtered leaf (`search_with_filter`), which the engine exposes
38 /// separately from the tuning leaves.
39 Dense {
40 /// Query vector.
41 query: &'a [f32],
42 /// Number of neighbours to return.
43 k: usize,
44 /// Optional `ef_search` override.
45 ef: Option<usize>,
46 /// Optional named search-quality profile.
47 quality: Option<crate::SearchQuality>,
48 /// Optional caller metadata filter.
49 filter: Option<&'a Filter>,
50 },
51 /// Full-text / BM25 search.
52 Text {
53 /// Query text.
54 query: &'a str,
55 /// Number of results to return.
56 k: usize,
57 /// Optional caller metadata filter.
58 filter: Option<&'a Filter>,
59 },
60 /// Hybrid dense + BM25 fused search.
61 Hybrid {
62 /// Dense query vector.
63 vector: &'a [f32],
64 /// Query text.
65 text: &'a str,
66 /// Number of results to return.
67 k: usize,
68 /// Optional dense/text blend factor.
69 alpha: Option<f32>,
70 /// Optional caller metadata filter.
71 filter: Option<&'a Filter>,
72 },
73}
74
75impl GatedRead<'_> {
76 /// The [`QueryOperationKind`] the observer is told this read represents, so
77 /// premium RBAC/audit records the correct operation label.
78 fn operation_kind(&self) -> QueryOperationKind {
79 match self {
80 GatedRead::Dense { .. } => QueryOperationKind::VectorSearch,
81 GatedRead::Text { .. } => QueryOperationKind::TextSearch,
82 GatedRead::Hybrid { .. } => QueryOperationKind::HybridSearch,
83 }
84 }
85}
86
87/// Lowers an [`AccessScope`](crate::observer::AccessScope) filter — expressed in
88/// the `VelesQL` [`Condition`](crate::velesql::Condition) language — into the
89/// lower-level [`filter::Filter`](crate::filter::Filter) the raw search leaves
90/// accept. Infallible: reuses the existing
91/// `From<velesql::Condition> for filter::Condition` conversion (the same
92/// lowering the WHERE evaluator uses).
93#[must_use]
94pub(crate) fn scope_to_core_filter(condition: crate::velesql::Condition) -> Filter {
95 Filter::new(Condition::from(condition))
96}
97
98/// AND-composes a caller filter with an observer scope filter. The result
99/// matches only rows satisfying both, so composing a scope can only narrow.
100fn and_filters(caller: Option<&Filter>, scope: Option<Filter>) -> Option<Filter> {
101 match (caller, scope) {
102 (None, None) => None,
103 (Some(c), None) => Some(c.clone()),
104 (None, Some(s)) => Some(s),
105 (Some(c), Some(s)) => Some(Filter::new(Condition::And {
106 conditions: vec![c.condition.clone(), s.condition],
107 })),
108 }
109}
110
111/// Runs a dense kNN search, picking the leaf by the effective filter and tuning.
112/// A filter (caller filter already AND-composed with any scope) routes through
113/// the filtered leaf; otherwise `ef` wins over `quality`, then plain search.
114fn run_dense(
115 coll: &VectorCollection,
116 query: &[f32],
117 k: usize,
118 ef: Option<usize>,
119 quality: Option<crate::SearchQuality>,
120 filter: Option<Filter>,
121) -> Result<Vec<SearchResult>> {
122 match filter {
123 Some(f) => coll.search_with_filter(query, k, &f),
124 None => match (ef, quality) {
125 (Some(ef), _) => coll.search_with_ef(query, k, ef),
126 (None, Some(q)) => coll.search_with_quality(query, k, q),
127 (None, None) => coll.search(query, k),
128 },
129 }
130}
131
132/// Dispatches a gated read to its collection search leaf, AND-composing the
133/// observer scope filter with any caller filter first.
134fn dispatch_gated_read(
135 coll: &VectorCollection,
136 read: GatedRead<'_>,
137 scope_filter: Option<Filter>,
138) -> Result<Vec<SearchResult>> {
139 match read {
140 GatedRead::Dense {
141 query,
142 k,
143 ef,
144 quality,
145 filter,
146 } => run_dense(
147 coll,
148 query,
149 k,
150 ef,
151 quality,
152 and_filters(filter, scope_filter),
153 ),
154 GatedRead::Text { query, k, filter } => match and_filters(filter, scope_filter) {
155 Some(f) => coll.text_search_with_filter(query, k, &f),
156 None => coll.text_search(query, k),
157 },
158 GatedRead::Hybrid {
159 vector,
160 text,
161 k,
162 alpha,
163 filter,
164 } => match and_filters(filter, scope_filter) {
165 Some(f) => coll.hybrid_search_with_filter(vector, text, k, alpha, &f),
166 None => coll.hybrid_search(vector, text, k, alpha),
167 },
168 }
169}
170
171impl Database {
172 /// Public read-gate check for search paths that do not map onto
173 /// [`GatedRead`] — sparse, batch, multi-query, graph-embedding and MATCH —
174 /// so their callers can enforce governance without a bespoke gated method
175 /// per return type. Consults the observer for the given collection /
176 /// operation / principal / tenant and reports what the caller must do:
177 ///
178 /// * `Ok(None)` — allow the read unmodified;
179 /// * `Ok(Some(filter))` — allow, but AND this scope filter into the search
180 /// (callers whose search variant cannot apply a metadata filter must fail
181 /// closed rather than run unfiltered);
182 /// * `Err(_)` — the read is denied (or the observer failed internally); the
183 /// caller must not touch the data plane.
184 ///
185 /// With no observer registered this is a single `Option` check returning
186 /// `Ok(None)` (zero-overhead contract).
187 ///
188 /// # Errors
189 ///
190 /// Returns the observer's `Deny` error when access is refused, or the
191 /// observer's own error on an internal failure.
192 pub fn authorize_read(
193 &self,
194 collection: &str,
195 operation: QueryOperationKind,
196 principal: Option<&str>,
197 tenant_hint: Option<&str>,
198 ) -> Result<Option<Filter>> {
199 match self.read_gate_raw(collection, operation, principal, tenant_hint)? {
200 RawGateOutcome::Allow => Ok(None),
201 RawGateOutcome::Deny(err) => Err(err),
202 RawGateOutcome::Scope(scope) => Ok(scope.filter.map(scope_to_core_filter)),
203 }
204 }
205
206 /// Executes a search through the control-plane read gate.
207 ///
208 /// Consults the registered observer via
209 /// [`read_gate_raw`](Self::read_gate_raw) for the
210 /// collection / operation / principal / tenant, then:
211 /// * [`Allow`](RawGateOutcome::Allow) — runs the search unmodified;
212 /// * [`Deny`](RawGateOutcome::Deny) — returns the supplied error and zero
213 /// results;
214 /// * [`Scope`](RawGateOutcome::Scope) — AND-composes the scope filter with
215 /// any caller filter before running the search.
216 ///
217 /// With no observer registered this is a single `Option` check followed by
218 /// the same leaf call the ungated path used (zero-overhead contract).
219 ///
220 /// # Errors
221 ///
222 /// Returns [`Error::CollectionNotFound`] if the collection does not exist,
223 /// the observer's `Deny` error when access is refused, or any error from the
224 /// underlying search leaf.
225 pub fn gated_search(
226 &self,
227 collection: &str,
228 principal: Option<&str>,
229 tenant_hint: Option<&str>,
230 read: GatedRead<'_>,
231 ) -> Result<Vec<SearchResult>> {
232 let scope_filter =
233 match self.read_gate_raw(collection, read.operation_kind(), principal, tenant_hint)? {
234 RawGateOutcome::Allow => None,
235 RawGateOutcome::Deny(err) => return Err(err),
236 RawGateOutcome::Scope(scope) => scope.filter.map(scope_to_core_filter),
237 };
238
239 let coll = self
240 .get_vector_collection(collection)
241 .ok_or_else(|| Error::CollectionNotFound(collection.to_string()))?;
242
243 dispatch_gated_read(&coll, read, scope_filter)
244 }
245}