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}