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(¬e.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(¬e.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}