Skip to main content

khive_runtime/operations/
identity_reads.rs

1use std::str::FromStr;
2
3use khive_storage::types::SqlValue;
4use uuid::Uuid;
5
6#[cfg(any(test, feature = "fault-injection"))]
7use super::{consume_fault, PREFIX_RESOLVE_FAIL_NS};
8use super::{resolve_prefix_statement, uuid_prefix_bounds, Resolved};
9use crate::error::{RuntimeError, RuntimeResult};
10use crate::runtime::{KhiveRuntime, NamespaceToken};
11
12impl KhiveRuntime {
13    /// Resolve a short UUID prefix (8+ hex chars) to a full UUID.
14    ///
15    /// Searches entities, notes, and edges tables for a UUID starting with the
16    /// given prefix, scoped to the caller's primary namespace only. Returns
17    /// `Ok(Some(uuid))` if exactly one match is found, `Ok(None)` if no
18    /// matches, or an error if ambiguous (multiple matches).
19    pub async fn resolve_prefix(
20        &self,
21        token: &NamespaceToken,
22        prefix: &str,
23    ) -> RuntimeResult<Option<Uuid>> {
24        let namespaces = [token.namespace().as_str().to_owned()];
25        self.resolve_prefix_inner(Some(&namespaces), prefix, false, false)
26            .await
27    }
28
29    pub async fn resolve_prefix_including_deleted(
30        &self,
31        token: &NamespaceToken,
32        prefix: &str,
33    ) -> RuntimeResult<Option<Uuid>> {
34        let namespaces = [token.namespace().as_str().to_owned()];
35        self.resolve_prefix_inner(Some(&namespaces), prefix, true, false)
36            .await
37    }
38
39    /// Resolve a short UUID prefix (8+ hex chars) to a full UUID with NO
40    /// namespace filter at all: mirrors `resolve_by_id`'s by-ID contract:
41    /// by-ID resolution is namespace-agnostic, since the Gate (not
42    /// storage-layer filtering) is the authz seam. Used by the four by-ID
43    /// CRUD verbs (get/update/delete/merge) so their prefix path matches
44    /// their already-unfiltered full-UUID path. No token param: unlike
45    /// `resolve_prefix`, there is no namespace to derive from one.
46    pub async fn resolve_prefix_unfiltered(&self, prefix: &str) -> RuntimeResult<Option<Uuid>> {
47        self.resolve_prefix_inner(None, prefix, false, false).await
48    }
49
50    /// `resolve_prefix_unfiltered`, including soft-deleted rows — used by the
51    /// hard-delete by-ID path.
52    pub async fn resolve_prefix_unfiltered_including_deleted(
53        &self,
54        prefix: &str,
55    ) -> RuntimeResult<Option<Uuid>> {
56        self.resolve_prefix_inner(None, prefix, true, false).await
57    }
58
59    /// The configured multi-backend read inventory has completed base schema
60    /// bootstrap. A missing table is a backend failure there, not an absent ID.
61    pub(crate) async fn resolve_prefix_for_kg_read(
62        &self,
63        prefix: &str,
64        include_deleted: bool,
65    ) -> RuntimeResult<Option<Uuid>> {
66        self.resolve_prefix_inner(None, prefix, include_deleted, true)
67            .await
68    }
69
70    /// Shared indexed prefix-range lookup over an explicit namespace set.
71    ///
72    /// `namespaces` selects the lookup scope: `Some(&[ns])` reproduces the
73    /// historical primary-only behaviour (`resolve_prefix` /
74    /// `resolve_prefix_including_deleted`); `None` applies
75    /// no namespace predicate at all (`resolve_prefix_unfiltered*`).
76    /// Ambiguity (a prefix matching more than one UUID, even across
77    /// different namespaces in the set, or across all namespaces when
78    /// unfiltered) is still an error: UUIDs are globally unique, so two
79    /// distinct rows sharing a prefix always requires caller disambiguation —
80    /// no cross-namespace dedup is needed or performed.
81    async fn resolve_prefix_inner(
82        &self,
83        namespaces: Option<&[String]>,
84        prefix: &str,
85        include_deleted: bool,
86        require_tables: bool,
87    ) -> RuntimeResult<Option<Uuid>> {
88        // Every caller is expected to pre-validate hex-only input, but this is
89        // the single choke point every `resolve_prefix*` variant funnels
90        // through, so re-validate here too. A prefix containing anything other
91        // than hex digits and canonical hyphen separators (`%`, `_`, or other
92        // injection-shaped input) never matches a real id and is rejected
93        // before it can reach the range query.
94        if !prefix.chars().all(|c| c.is_ascii_hexdigit() || c == '-') {
95            return Ok(None);
96        }
97
98        // Injection: check PREFIX_RESOLVE_FAIL_NS (armed by
99        // `arm_prefix_resolve_fail_scoped(prefix)`), exercising the storage-failure path
100        // a genuine pool checkout timeout or WAL contention would take.
101        #[cfg(any(test, feature = "fault-injection"))]
102        if consume_fault(&PREFIX_RESOLVE_FAIL_NS, prefix) {
103            return Err(RuntimeError::Storage(
104                khive_storage::StorageError::Timeout {
105                    operation: "resolve_prefix".into(),
106                },
107            ));
108        }
109
110        let Some((lower, upper)) = uuid_prefix_bounds(prefix) else {
111            return Ok(None);
112        };
113
114        let tables = [
115            ("entities", true),
116            ("notes", true),
117            ("events", false),
118            ("graph_edges", false),
119        ];
120
121        // A UUID can legitimately exist in more than one scanned table
122        // (e.g. an entity id string that also happens to be an edge id — the
123        // lookup is a text-prefix range across independent tables, not a
124        // substrate-exclusive lookup). Without dedup, a single record hit
125        // twice across tables inflated `matches.len()` past 1 and produced a
126        // false `AmbiguousPrefix` naming the SAME UUID twice. `seen` tracks
127        // UUIDs already pushed so `matches` (and thus every length check,
128        // including the early-exit below) reflects DISTINCT UUIDs only.
129        let mut matches: Vec<String> = Vec::new();
130        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
131        let mut reader = self.sql().reader().await.map_err(RuntimeError::Storage)?;
132
133        for (table, has_deleted_at) in tables {
134            let sql = resolve_prefix_statement(
135                table,
136                has_deleted_at,
137                include_deleted,
138                namespaces,
139                &lower,
140                &upper,
141            );
142            match reader.query_all(sql).await {
143                Ok(rows) => {
144                    for row in rows {
145                        if let Some(col) = row.columns.first() {
146                            if let SqlValue::Text(s) = &col.value {
147                                if seen.insert(s.clone()) {
148                                    matches.push(s.clone());
149                                }
150                            }
151                        }
152                    }
153                }
154                Err(e) => {
155                    let msg = e.to_string();
156                    if !require_tables && msg.contains("no such table") {
157                        continue;
158                    }
159                    return Err(RuntimeError::Storage(e));
160                }
161            }
162            if matches.len() > 1 {
163                break;
164            }
165        }
166
167        // Sidecar-resident event rows (events-split lane) are invisible to the
168        // main-store scan above; a prefix naming one must still resolve, and a
169        // prefix colliding across the two files must still read as ambiguous,
170        // so the sidecar scan merges into the same `matches`/`seen` set.
171        if matches.len() <= 1 {
172            if let Some(sidecar_sql) = self.events_sidecar_sql_read_only()? {
173                // The main-store statement, so the sidecar's candidates come back in
174                // the same id order rather than whichever index the planner picks.
175                let mut sql = resolve_prefix_statement(
176                    "events",
177                    false,
178                    include_deleted,
179                    namespaces,
180                    &lower,
181                    &upper,
182                );
183                sql.label = Some("resolve_prefix.events_sidecar".into());
184                let mut sidecar_reader =
185                    sidecar_sql.reader().await.map_err(RuntimeError::Storage)?;
186                match sidecar_reader.query_all(sql).await {
187                    Ok(rows) => {
188                        for row in rows {
189                            if let Some(col) = row.columns.first() {
190                                if let SqlValue::Text(s) = &col.value {
191                                    if seen.insert(s.clone()) {
192                                        matches.push(s.clone());
193                                    }
194                                }
195                            }
196                        }
197                    }
198                    Err(e) => {
199                        let msg = e.to_string();
200                        if require_tables || !msg.contains("no such table") {
201                            return Err(RuntimeError::Storage(e));
202                        }
203                    }
204                }
205            }
206        }
207
208        match matches.len() {
209            0 => Ok(None),
210            1 => {
211                let uuid = Uuid::from_str(&matches[0])
212                    .map_err(|e| RuntimeError::Internal(format!("stored UUID is invalid: {e}")))?;
213                Ok(Some(uuid))
214            }
215            _ => {
216                let uuids: Vec<uuid::Uuid> = matches
217                    .iter()
218                    .filter_map(|s| Uuid::from_str(s).ok())
219                    .collect();
220                Err(RuntimeError::AmbiguousPrefix {
221                    prefix: prefix.to_string(),
222                    matches: uuids,
223                })
224            }
225        }
226    }
227
228    /// Resolve a UUID to its substrate kind with NO namespace filter.
229    ///
230    /// By-ID contract: UUID v4 is globally unique: by-ID substrate
231    /// inference must return the record regardless of caller namespace.  Used by
232    /// the public `update` and `delete` verb handlers when no explicit `kind` is
233    /// supplied.
234    ///
235    /// Does NOT consult the visible set or the primary-namespace check.  The
236    /// token is still required to route to the correct backend pool but its
237    /// namespace value is not used as a filter.
238    pub async fn resolve_by_id(
239        &self,
240        token: &NamespaceToken,
241        id: Uuid,
242    ) -> RuntimeResult<Option<Resolved>> {
243        self.resolve_records(token, id, false, None, false).await
244    }
245
246    /// Resolve a UUID to its substrate kind with NO namespace filter, including
247    /// soft-deleted rows.
248    ///
249    /// Used by the hard-delete path when no explicit `kind` is supplied, so
250    /// already-soft-deleted records can still be located by UUID alone.
251    pub async fn resolve_by_id_including_deleted(
252        &self,
253        token: &NamespaceToken,
254        id: Uuid,
255    ) -> RuntimeResult<Option<Resolved>> {
256        self.resolve_records(token, id, true, None, false).await
257    }
258
259    /// Resolve a UUID to its substrate kind by trying entity, then note, then event stores.
260    ///
261    /// Returns `None` if the UUID is not found in any substrate.
262    /// Cost: at most 3 store lookups per call (cheap for v0.1).
263    pub async fn resolve(
264        &self,
265        token: &NamespaceToken,
266        id: Uuid,
267    ) -> RuntimeResult<Option<Resolved>> {
268        // Entity: use the namespace-checked getter (errors on mismatch/absent).
269        match self.get_entity(token, id).await {
270            Ok(entity) => return Ok(Some(Resolved::Entity(entity))),
271            Err(RuntimeError::NotFound(_) | RuntimeError::NamespaceMismatch { .. }) => {}
272            Err(e) => return Err(e),
273        }
274
275        // Note: storage get_note is ID-only — verify against visible set.
276        if let Some(note) = self.notes(token)?.get_note(id).await? {
277            if Self::ensure_namespace_visible(&note.namespace, token).is_ok() {
278                return Ok(Some(Resolved::Note(note)));
279            }
280        }
281
282        // Event: storage get_event is ID-only — verify against visible set.
283        if let Some(event) = self.events(token)?.get_event(id).await? {
284            if Self::ensure_namespace_visible(&event.namespace, token).is_ok() {
285                return Ok(Some(Resolved::Event(event)));
286            }
287        }
288
289        Ok(None)
290    }
291
292    /// Resolve a UUID to its substrate kind with NO namespace filter, for edge
293    /// endpoint validation.
294    ///
295    /// `link` and `create`'s `annotates` targets consume by-ID endpoints, so
296    /// their existence check must follow the same by-ID contract as `get()`:
297    /// by-ID ops are namespace-agnostic: the Gate, not storage-layer
298    /// filtering, is the authz seam. Mirrors `resolve_by_id`
299    /// (entity + note, unfiltered) and additionally resolves events,
300    /// unfiltered, so edge endpoint validation resolves exactly what `get()`
301    /// resolves regardless of the caller's namespace.
302    pub async fn resolve_edge_endpoint(
303        &self,
304        token: &NamespaceToken,
305        id: Uuid,
306    ) -> RuntimeResult<Option<Resolved>> {
307        self.resolve_records(token, id, false, None, true).await
308    }
309
310    /// Resolve a UUID to its substrate kind using primary-namespace-only enforcement.
311    ///
312    /// Unlike `resolve`, never consults the visible set. Use from GTD dependency
313    /// validation paths where strict primary ownership is required.
314    pub async fn resolve_primary(
315        &self,
316        token: &NamespaceToken,
317        id: Uuid,
318    ) -> RuntimeResult<Option<Resolved>> {
319        self.resolve_records(token, id, false, Some(token.namespace().as_str()), true)
320            .await
321    }
322
323    /// Resolve a UUID to its substrate kind, including soft-deleted rows.
324    ///
325    /// Used exclusively by the hard-delete path to locate records that have
326    /// already been soft-deleted. Namespace isolation is still enforced.
327    pub async fn resolve_including_deleted(
328        &self,
329        token: &NamespaceToken,
330        id: Uuid,
331    ) -> RuntimeResult<Option<Resolved>> {
332        self.resolve_records(token, id, true, Some(token.namespace().as_str()), true)
333            .await
334    }
335
336    // Preserve entity-before-note-before-event lookup and lazy store access. A namespace
337    // mismatch falls through; None deliberately leaves by-ID reads namespace-agnostic.
338    // Tombstones apply only to entities/notes, and by-ID CRUD never probes events.
339    async fn resolve_records(
340        &self,
341        token: &NamespaceToken,
342        id: Uuid,
343        include_deleted: bool,
344        namespace: Option<&str>,
345        include_events: bool,
346    ) -> RuntimeResult<Option<Resolved>> {
347        let entity = if include_deleted {
348            self.entities(token)?
349                .get_entity_including_deleted(id)
350                .await?
351        } else {
352            self.entities(token)?.get_entity(id).await?
353        };
354        if let Some(entity) = entity {
355            if namespace.is_none_or(|ns| Self::ensure_namespace(&entity.namespace, ns).is_ok()) {
356                return Ok(Some(Resolved::Entity(entity)));
357            }
358        }
359
360        let note = if include_deleted {
361            self.notes(token)?.get_note_including_deleted(id).await?
362        } else {
363            self.notes(token)?.get_note(id).await?
364        };
365        if let Some(note) = note {
366            if namespace.is_none_or(|ns| Self::ensure_namespace(&note.namespace, ns).is_ok()) {
367                return Ok(Some(Resolved::Note(note)));
368            }
369        }
370
371        if include_events {
372            if let Some(event) = self.events(token)?.get_event(id).await? {
373                if namespace.is_none_or(|ns| Self::ensure_namespace(&event.namespace, ns).is_ok()) {
374                    return Ok(Some(Resolved::Event(event)));
375                }
376            }
377        }
378
379        Ok(None)
380    }
381}