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