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        // Entity: direct by-UUID fetch (ID-only, no namespace check).
244        if let Some(entity) = self.entities(token)?.get_entity(id).await? {
245            return Ok(Some(Resolved::Entity(entity)));
246        }
247
248        // Note: direct by-UUID fetch (ID-only).
249        if let Some(note) = self.notes(token)?.get_note(id).await? {
250            return Ok(Some(Resolved::Note(note)));
251        }
252
253        // Edges and events are not returned here; the caller's `_` arm handles
254        // those with a separate get_edge / get_event check.
255        Ok(None)
256    }
257
258    /// Resolve a UUID to its substrate kind with NO namespace filter, including
259    /// soft-deleted rows.
260    ///
261    /// Used by the hard-delete path when no explicit `kind` is supplied, so
262    /// already-soft-deleted records can still be located by UUID alone.
263    pub async fn resolve_by_id_including_deleted(
264        &self,
265        token: &NamespaceToken,
266        id: Uuid,
267    ) -> RuntimeResult<Option<Resolved>> {
268        // Entity: including soft-deleted, no namespace check.
269        if let Some(entity) = self
270            .entities(token)?
271            .get_entity_including_deleted(id)
272            .await?
273        {
274            return Ok(Some(Resolved::Entity(entity)));
275        }
276
277        // Note: including soft-deleted, no namespace check.
278        if let Some(note) = self.notes(token)?.get_note_including_deleted(id).await? {
279            return Ok(Some(Resolved::Note(note)));
280        }
281
282        // Edges and events are not returned here; the caller's `_` arm handles
283        // those with a separate get_edge_including_deleted check.
284        Ok(None)
285    }
286
287    /// Resolve a UUID to its substrate kind by trying entity, then note, then event stores.
288    ///
289    /// Returns `None` if the UUID is not found in any substrate.
290    /// Cost: at most 3 store lookups per call (cheap for v0.1).
291    pub async fn resolve(
292        &self,
293        token: &NamespaceToken,
294        id: Uuid,
295    ) -> RuntimeResult<Option<Resolved>> {
296        // Entity: use the namespace-checked getter (errors on mismatch/absent).
297        match self.get_entity(token, id).await {
298            Ok(entity) => return Ok(Some(Resolved::Entity(entity))),
299            Err(RuntimeError::NotFound(_) | RuntimeError::NamespaceMismatch { .. }) => {}
300            Err(e) => return Err(e),
301        }
302
303        // Note: storage get_note is ID-only — verify against visible set.
304        if let Some(note) = self.notes(token)?.get_note(id).await? {
305            if Self::ensure_namespace_visible(&note.namespace, token).is_ok() {
306                return Ok(Some(Resolved::Note(note)));
307            }
308        }
309
310        // Event: storage get_event is ID-only — verify against visible set.
311        if let Some(event) = self.events(token)?.get_event(id).await? {
312            if Self::ensure_namespace_visible(&event.namespace, token).is_ok() {
313                return Ok(Some(Resolved::Event(event)));
314            }
315        }
316
317        Ok(None)
318    }
319
320    /// Resolve a UUID to its substrate kind with NO namespace filter, for edge
321    /// endpoint validation.
322    ///
323    /// `link` and `create`'s `annotates` targets consume by-ID endpoints, so
324    /// their existence check must follow the same by-ID contract as `get()`:
325    /// by-ID ops are namespace-agnostic: the Gate, not storage-layer
326    /// filtering, is the authz seam. Mirrors `resolve_by_id`
327    /// (entity + note, unfiltered) and additionally resolves events,
328    /// unfiltered, so edge endpoint validation resolves exactly what `get()`
329    /// resolves regardless of the caller's namespace.
330    pub async fn resolve_edge_endpoint(
331        &self,
332        token: &NamespaceToken,
333        id: Uuid,
334    ) -> RuntimeResult<Option<Resolved>> {
335        if let Some(resolved) = self.resolve_by_id(token, id).await? {
336            return Ok(Some(resolved));
337        }
338        if let Some(event) = self.events(token)?.get_event(id).await? {
339            return Ok(Some(Resolved::Event(event)));
340        }
341        Ok(None)
342    }
343
344    /// Resolve a UUID to its substrate kind using primary-namespace-only enforcement.
345    ///
346    /// Unlike `resolve`, never consults the visible set. Use from GTD dependency
347    /// validation paths where strict primary ownership is required.
348    pub async fn resolve_primary(
349        &self,
350        token: &NamespaceToken,
351        id: Uuid,
352    ) -> RuntimeResult<Option<Resolved>> {
353        self.resolve_primary_inner(token, id, false).await
354    }
355
356    /// Resolve a UUID to its substrate kind, including soft-deleted rows.
357    ///
358    /// Used exclusively by the hard-delete path to locate records that have
359    /// already been soft-deleted. Namespace isolation is still enforced.
360    pub async fn resolve_including_deleted(
361        &self,
362        token: &NamespaceToken,
363        id: Uuid,
364    ) -> RuntimeResult<Option<Resolved>> {
365        self.resolve_primary_inner(token, id, true).await
366    }
367
368    // Each arm accepts a record only from the caller's primary namespace; a record in a
369    // visible-only namespace is refused and the lookup falls through to the next substrate.
370    // Tombstone selection applies only to entities and notes; events keep their live lookup.
371    async fn resolve_primary_inner(
372        &self,
373        token: &NamespaceToken,
374        id: Uuid,
375        include_deleted: bool,
376    ) -> RuntimeResult<Option<Resolved>> {
377        let ns = token.namespace().as_str();
378
379        let entity = if include_deleted {
380            self.entities(token)?
381                .get_entity_including_deleted(id)
382                .await?
383        } else {
384            self.entities(token)?.get_entity(id).await?
385        };
386        if let Some(entity) = entity {
387            if Self::ensure_namespace(&entity.namespace, ns).is_ok() {
388                return Ok(Some(Resolved::Entity(entity)));
389            }
390        }
391
392        let note = if include_deleted {
393            self.notes(token)?.get_note_including_deleted(id).await?
394        } else {
395            self.notes(token)?.get_note(id).await?
396        };
397        if let Some(note) = note {
398            if Self::ensure_namespace(&note.namespace, ns).is_ok() {
399                return Ok(Some(Resolved::Note(note)));
400            }
401        }
402
403        if let Some(event) = self.events(token)?.get_event(id).await? {
404            if Self::ensure_namespace(&event.namespace, ns).is_ok() {
405                return Ok(Some(Resolved::Event(event)));
406            }
407        }
408
409        Ok(None)
410    }
411}