Skip to main content

velesdb_mobile/
observer.rs

1//! `MobileObserver` — mobile read-path control-plane hook (audit F-5.4, #1392).
2//!
3//! # Governance parity on mobile
4//!
5//! The core read gate (`Database::open_with_observer` +
6//! [`Database::gated_search`](velesdb_core::Database::gated_search) /
7//! [`Database::authorize_read`](velesdb_core::Database::authorize_read)) is
8//! already wired on `server` and `python`, and notify-only on `tauri`, but was
9//! historically **absent** on mobile: [`crate::VelesDatabase`] opened via
10//! `Database::open` and every read went through a *detached*
11//! [`VectorCollection`](velesdb_core::VectorCollection) leaf that has no
12//! observer reference. This module restores parity.
13//!
14//! Unlike the WASM sibling — whose single-threaded `Rc<RefCell<…>>` store
15//! cannot satisfy core's `Send + Sync` observer bound and therefore mirrors the
16//! contract with a wasm-local trait — mobile's [`crate::VelesDatabase`] is a
17//! `Send + Sync` UniFFI object. It can (and does) wire the **real** core
18//! [`DatabaseObserver`](velesdb_core::DatabaseObserver) seam directly, so a
19//! denying observer actually fails the read closed and an
20//! `AllowWithScope` observer narrows results — this is a genuine gate, not
21//! notify-only.
22//!
23//! # Foreign (Kotlin / Swift) observers
24//!
25//! [`MobileObserver`] is a UniFFI **foreign-implementable** trait
26//! (`with_foreign`): a Kotlin or Swift class can implement it and register it
27//! through
28//! [`VelesDatabase::open_with_observer`](crate::VelesDatabase::open_with_observer).
29//! [`ForeignObserver`] adapts such an instance to core's `DatabaseObserver` so
30//! every governed read consults it before touching the store.
31//!
32//! # Contract (inherited from core)
33//!
34//! - [`MobileObserver::on_query_request`] defaults to [`MobileAccessDecision::Allow`],
35//!   so an observer overriding nothing behaves exactly as no observer at all.
36//! - Implementations MUST NOT panic.
37//! - Denial flows through [`MobileAccessDecision::Deny`], **not** an error
38//!   channel: `Deny` carries the message surfaced to the caller (the read
39//!   returns that error and zero results), `Allow` executes unmodified.
40//! - With no observer registered the gate is a single `Option` check (the
41//!   zero-overhead contract of the core gate).
42//!
43//! # Follow-up
44//!
45//! Scope narrowing (`AccessDecision::AllowWithScope`) is honoured end-to-end for
46//! observers wired at the Rust level, but is **not** yet expressible from the
47//! foreign `MobileAccessDecision` enum (which carries only `Allow` / `Deny`),
48//! mirroring the WASM decision surface. Surfacing a foreign scope filter is an
49//! additive follow-up; adding a variant to `MobileAccessDecision` is
50//! non-breaking.
51
52use std::sync::Arc;
53
54use velesdb_core::{
55    AccessDecision as CoreAccessDecision, DatabaseObserver, Error as CoreError,
56    QueryAccessContext as CoreQueryAccessContext, QueryOperationKind as CoreQueryOperationKind,
57};
58
59/// The read operation being gated (UniFFI mirror of core's `QueryOperationKind`).
60#[derive(uniffi::Enum, Clone, Copy, Debug, PartialEq, Eq)]
61pub enum MobileQueryOperationKind {
62    /// Dense vector similarity search.
63    VectorSearch,
64    /// Full-text / BM25 search.
65    TextSearch,
66    /// Hybrid (dense + text/sparse) fused search.
67    HybridSearch,
68    /// Graph traversal (`VelesQL` MATCH).
69    GraphTraversal,
70    /// Relational-style `VelesQL` SELECT (incl. JOIN / aggregation).
71    Select,
72}
73
74impl From<CoreQueryOperationKind> for MobileQueryOperationKind {
75    fn from(kind: CoreQueryOperationKind) -> Self {
76        match kind {
77            CoreQueryOperationKind::VectorSearch => Self::VectorSearch,
78            CoreQueryOperationKind::TextSearch => Self::TextSearch,
79            CoreQueryOperationKind::HybridSearch => Self::HybridSearch,
80            CoreQueryOperationKind::GraphTraversal => Self::GraphTraversal,
81            CoreQueryOperationKind::Select => Self::Select,
82            // `QueryOperationKind` is `#[non_exhaustive]`; a future kind is
83            // reported to foreign observers as the generic relational read so
84            // the callback still fires and the gate still runs (advisory hint).
85            _ => Self::Select,
86        }
87    }
88}
89
90/// Read-time context handed to a foreign [`MobileObserver`].
91///
92/// Owned (not borrowed) because it crosses the UniFFI boundary. `principal` and
93/// `tenant_hint` are opaque, caller-supplied identity/tenant hints forwarded
94/// untouched — the gate never interprets them (they are only meaningful when a
95/// trusted embedder forwards a verified identity: the local-SDK trust boundary).
96#[derive(uniffi::Record, Clone, Debug)]
97pub struct MobileQueryContext {
98    /// Target collection name.
99    pub collection: String,
100    /// Which read path is executing.
101    pub operation: MobileQueryOperationKind,
102    /// Opaque caller-supplied principal hint, forwarded untouched.
103    pub principal: Option<String>,
104    /// Opaque caller-supplied tenant hint, forwarded untouched.
105    pub tenant_hint: Option<String>,
106}
107
108/// The control-plane decision returned by a foreign [`MobileObserver`].
109///
110/// Kept intentionally small for the FFI boundary: `Allow` executes the read
111/// unmodified, `Deny { reason }` aborts with `reason` and zero results. Scope
112/// narrowing (`AllowWithScope` in core) is deliberately **not** replicated at
113/// the foreign boundary yet — see the module-level follow-up note; adding a
114/// variant later is additive.
115#[derive(uniffi::Enum, Clone, Debug)]
116pub enum MobileAccessDecision {
117    /// Execute the read unmodified. Default decision.
118    Allow,
119    /// Abort the read and surface `reason` without producing results.
120    Deny {
121        /// Human-readable denial reason surfaced to the caller.
122        reason: String,
123    },
124}
125
126/// Foreign-implementable read-path observer (UniFFI callback interface).
127///
128/// A Kotlin/Swift class implements this trait and registers it via
129/// [`VelesDatabase::open_with_observer`](crate::VelesDatabase::open_with_observer).
130/// Every governed read routed through the database (dense / text / hybrid /
131/// sparse / multi-query search, `VelesQL` `SELECT` / `MATCH`) consults it before
132/// touching the store.
133#[uniffi::export(with_foreign)]
134pub trait MobileObserver: Send + Sync {
135    /// Called immediately before a read executes. Returns a
136    /// [`MobileAccessDecision`].
137    ///
138    /// Implementations MUST NOT panic. Denial is expressed through
139    /// [`MobileAccessDecision::Deny`], never by throwing.
140    fn on_query_request(&self, context: MobileQueryContext) -> MobileAccessDecision;
141}
142
143/// Adapts a foreign [`MobileObserver`] to core's [`DatabaseObserver`] so it can
144/// be injected through
145/// [`Database::open_with_observer`](velesdb_core::Database::open_with_observer).
146///
147/// Only the read-path hook (`on_query_request`) is bridged; the lifecycle hooks
148/// keep their no-op defaults (mobile has no event stream to forward them to).
149pub(crate) struct ForeignObserver {
150    inner: Arc<dyn MobileObserver>,
151}
152
153impl ForeignObserver {
154    /// Wraps a foreign observer as a core-compatible `DatabaseObserver`.
155    pub(crate) fn new(inner: Arc<dyn MobileObserver>) -> Self {
156        Self { inner }
157    }
158}
159
160impl DatabaseObserver for ForeignObserver {
161    fn on_query_request(
162        &self,
163        ctx: &CoreQueryAccessContext,
164    ) -> velesdb_core::Result<CoreAccessDecision> {
165        let context = MobileQueryContext {
166            collection: ctx.collection.to_string(),
167            operation: ctx.operation.into(),
168            principal: ctx.principal.map(str::to_string),
169            tenant_hint: ctx.tenant_hint.map(str::to_string),
170        };
171        // Denial is a decision value, not an internal-failure `Err`: map the
172        // foreign `Deny` to `AccessDecision::Deny` (mirrors `PyObserver`).
173        Ok(match self.inner.on_query_request(context) {
174            MobileAccessDecision::Allow => CoreAccessDecision::Allow,
175            MobileAccessDecision::Deny { reason } => CoreAccessDecision::Deny(CoreError::Query(
176                format!("read denied by observer: {reason}"),
177            )),
178        })
179    }
180}