Skip to main content

khive_runtime/graph_lint/
capture.rs

1use crate::{KhiveRuntime, NamespaceToken, RuntimeResult, VerbRegistry};
2use khive_storage::{Edge, Entity, Note};
3use sha2::{Digest, Sha256};
4use std::collections::{HashMap, HashSet};
5use uuid::Uuid;
6
7/// Already-visible edge and authorized endpoint observations. No backend handles escape.
8#[derive(Clone, Debug)]
9pub struct InspectedLintEdge {
10    pub edge: Edge,
11    pub source_live: bool,
12    pub target_live: bool,
13    pub source_soft_deleted: bool,
14    pub target_soft_deleted: bool,
15    pub contract_valid: Option<bool>,
16}
17/// Read-only observation owned by one invocation, retaining private mutation provenance.
18pub struct LintCapture {
19    pub(super) token: NamespaceToken,
20    pub(super) entities: Vec<Entity>,
21    pub(super) notes: Vec<Note>,
22    pub(super) edges: Vec<InspectedLintEdge>,
23    pub(super) owners: Vec<KhiveRuntime>,
24    pub(super) record_owners: HashMap<Uuid, Vec<usize>>,
25    pub(super) all_entities: HashMap<Uuid, Entity>,
26    pub(super) all_notes: HashMap<Uuid, Note>,
27    pub(super) all_edges: HashMap<Uuid, Edge>,
28    pub(super) edge_guards: HashMap<Uuid, khive_storage::SqlStatement>,
29}
30impl LintCapture {
31    pub fn entities(&self) -> &[Entity] {
32        &self.entities
33    }
34    pub fn notes(&self) -> &[Note] {
35        &self.notes
36    }
37    pub fn edges(&self) -> &[InspectedLintEdge] {
38        &self.edges
39    }
40}
41
42const PAGE: u32 = 256;
43fn active() -> RuntimeResult<()> {
44    khive_storage::ensure_request_read_active("knowledge.lint capture").map_err(Into::into)
45}
46fn visible(token: &NamespaceToken, namespace: &str) -> bool {
47    token
48        .visible_namespaces()
49        .iter()
50        .any(|ns| ns.as_str() == namespace)
51}
52/// A digest of the serialized record. Identity checks compare digests, so the capture does
53/// not hold a third copy of every visible record (note bodies included) for them.
54fn canonical<T: serde::Serialize>(value: &T) -> RuntimeResult<[u8; 32]> {
55    let bytes = serde_json::to_vec(value)
56        .map_err(|error| crate::RuntimeError::Internal(error.to_string()))?;
57    let mut digest = [0u8; 32];
58    digest.copy_from_slice(&Sha256::digest(bytes));
59    Ok(digest)
60}
61fn register<T: serde::Serialize>(
62    identities: &mut HashMap<Uuid, (&'static str, [u8; 32])>,
63    owners: &mut HashMap<Uuid, Vec<usize>>,
64    id: Uuid,
65    substrate: &'static str,
66    value: &T,
67    owner: usize,
68) -> RuntimeResult<bool> {
69    let value = canonical(value)?;
70    if let Some((prior_substrate, prior)) = identities.get(&id) {
71        if *prior_substrate != substrate || *prior != value {
72            return Err(crate::RuntimeError::InvalidInput(format!(
73                "contradictory visible graph records for {id}"
74            )));
75        }
76        let ids = owners.entry(id).or_default();
77        if !ids.contains(&owner) {
78            ids.push(owner);
79        }
80        return Ok(false);
81    }
82    identities.insert(id, (substrate, value));
83    owners.insert(id, vec![owner]);
84    Ok(true)
85}
86impl VerbRegistry {
87    /// Capture all registered physical graph owners under the caller's token.
88    /// No snapshot or mutation ownership is inferred from read deduplication.
89    pub async fn capture_graph_lint(
90        &self,
91        runtime: &KhiveRuntime,
92        token: &NamespaceToken,
93    ) -> RuntimeResult<LintCapture> {
94        khive_storage::await_request_read_phase(
95            "knowledge.lint capture",
96            self.capture_graph_lint_inner(runtime, token),
97        )
98        .await?
99    }
100    async fn capture_graph_lint_inner(
101        &self,
102        runtime: &KhiveRuntime,
103        token: &NamespaceToken,
104    ) -> RuntimeResult<LintCapture> {
105        let registered = self.graph_lint_runtimes(runtime);
106        let backend_aliases: HashSet<_> = registered
107            .iter()
108            .map(|owner| owner.backend_id().as_str().to_owned())
109            .collect();
110        let mut owners: Vec<KhiveRuntime> = Vec::new();
111        for owner in registered {
112            if !owners
113                .iter()
114                .any(|existing| existing.shares_backend_storage_with(&owner))
115            {
116                owners.push(owner);
117            }
118        }
119        let rules = self.all_edge_rules();
120        let mut capture = LintCapture {
121            token: token.clone(),
122            entities: Vec::new(),
123            notes: Vec::new(),
124            edges: Vec::new(),
125            owners,
126            record_owners: HashMap::new(),
127            all_entities: HashMap::new(),
128            all_notes: HashMap::new(),
129            all_edges: HashMap::new(),
130            edge_guards: HashMap::new(),
131        };
132        let mut identities = HashMap::new();
133        let mut all_edges = HashMap::new();
134        let mut hidden = HashSet::new();
135        let mailbox = crate::MailboxView {
136            actor_id: token.actor().id.clone(),
137            delegated: false,
138        };
139        for (owner, runtime) in capture.owners.iter().enumerate() {
140            let mut after = None;
141            loop {
142                active()?;
143                let filter = khive_storage::EntityFilter {
144                    tombstones: khive_storage::entity::EntityTombstones::All,
145                    ..Default::default()
146                };
147                let (entities, next) = runtime
148                    .list_entities_after_filtered(token, filter, after, PAGE)
149                    .await?;
150                for entity in entities {
151                    if register(
152                        &mut identities,
153                        &mut capture.record_owners,
154                        entity.id,
155                        "entity",
156                        &entity,
157                        owner,
158                    )? {
159                        if entity.deleted_at.is_none() {
160                            capture.entities.push(entity.clone());
161                        }
162                        capture.all_entities.insert(entity.id, entity);
163                    }
164                }
165                match next {
166                    None => break,
167                    Some(next) if Some(next) == after => {
168                        return Err(crate::RuntimeError::Internal(
169                            "entity lint cursor did not advance".into(),
170                        ))
171                    }
172                    Some(next) => after = Some(next),
173                }
174            }
175            let store = runtime.notes(token)?;
176            let filter = khive_storage::note::NoteFilter {
177                namespaces: token
178                    .visible_namespaces()
179                    .iter()
180                    .map(|ns| ns.as_str().to_owned())
181                    .collect(),
182                include_deleted: true,
183                ..Default::default()
184            };
185            let mut after = None;
186            loop {
187                active()?;
188                let page = store
189                    .query_notes_filtered_after(token.namespace().as_str(), &filter, after, PAGE)
190                    .await?;
191                for note in page.items {
192                    if !mailbox.permits_message_note(token, &note) {
193                        hidden.insert(note.id);
194                        continue;
195                    }
196                    if note.salience.is_some_and(|v| !v.is_finite())
197                        || note.decay_factor.is_some_and(|v| !v.is_finite())
198                    {
199                        return Err(crate::RuntimeError::InvalidInput(
200                            "nonfinite visible note value".into(),
201                        ));
202                    }
203                    if register(
204                        &mut identities,
205                        &mut capture.record_owners,
206                        note.id,
207                        "note",
208                        &note,
209                        owner,
210                    )? {
211                        if note.deleted_at.is_none() {
212                            capture.notes.push(note.clone());
213                        }
214                        capture.all_notes.insert(note.id, note);
215                    }
216                }
217                match page.next_after {
218                    None => break,
219                    Some(next) if Some(next) == after => {
220                        return Err(crate::RuntimeError::Internal(
221                            "note lint cursor did not advance".into(),
222                        ))
223                    }
224                    Some(next) => after = Some(next),
225                }
226            }
227            for ns in token.visible_namespaces() {
228                let mut after: Option<String> = None;
229                loop {
230                    active()?;
231                    let rows = runtime
232                        .sql()
233                        .reader()
234                        .await?
235                        .query_all(khive_db::graph_lint::edge_page(
236                            ns.as_str(),
237                            after.as_deref(),
238                            PAGE,
239                        ))
240                        .await?;
241                    if rows.is_empty() {
242                        break;
243                    }
244                    for row in rows {
245                        let edge = khive_db::graph_lint::decode_edge(&row)?;
246                        let id = Uuid::from(edge.id);
247                        if register(
248                            &mut identities,
249                            &mut capture.record_owners,
250                            id,
251                            "edge",
252                            &edge,
253                            owner,
254                        )? {
255                            capture
256                                .edge_guards
257                                .insert(id, khive_db::graph_lint::edge_witness(&row)?);
258                            all_edges.insert(id, edge);
259                        }
260                        after = Some(
261                            row.text("id")
262                                .map_err(|error| crate::RuntimeError::Internal(error.to_string()))?
263                                .to_owned(),
264                        );
265                    }
266                }
267            }
268        }
269        // Resolve endpoint absence separately from hidden records. By-ID APIs
270        // alone confer no visibility: every returned row is screened again.
271        let endpoint_ids: HashSet<_> = all_edges
272            .values()
273            .flat_map(|edge| [edge.source_id, edge.target_id])
274            .collect();
275        let mut events = HashMap::new();
276        for id in endpoint_ids {
277            active()?;
278            // The listing passes above captured this record and recorded its owners. The
279            // capture holds no snapshot, so reading it again would compare it with a later
280            // state of the store; only the event store, which no listing covers, is probed.
281            let listed = identities.contains_key(&id);
282            for (owner, runtime) in capture.owners.iter().enumerate() {
283                if !listed {
284                    if let Some(entity) = runtime.get_entity_including_deleted(token, id).await? {
285                        if !visible(token, &entity.namespace) {
286                            hidden.insert(id);
287                            continue;
288                        }
289                        register(
290                            &mut identities,
291                            &mut capture.record_owners,
292                            id,
293                            "entity",
294                            &entity,
295                            owner,
296                        )?;
297                        capture.all_entities.entry(id).or_insert(entity);
298                    }
299                    if let Some(note) = runtime.get_note_including_deleted(token, id).await? {
300                        if !visible(token, &note.namespace)
301                            || !mailbox.permits_message_note(token, &note)
302                        {
303                            hidden.insert(id);
304                            continue;
305                        }
306                        register(
307                            &mut identities,
308                            &mut capture.record_owners,
309                            id,
310                            "note",
311                            &note,
312                            owner,
313                        )?;
314                        capture.all_notes.entry(id).or_insert(note);
315                    }
316                    if let Some(edge) = runtime.get_edge_including_deleted(token, id).await? {
317                        if !visible(token, &edge.namespace) {
318                            hidden.insert(id);
319                            continue;
320                        }
321                        register(
322                            &mut identities,
323                            &mut capture.record_owners,
324                            id,
325                            "edge",
326                            &edge,
327                            owner,
328                        )?;
329                    }
330                }
331                if let Some(event) = runtime.events(token)?.get_event(id).await? {
332                    if !visible(token, &event.namespace) {
333                        hidden.insert(id);
334                        continue;
335                    }
336                    register(
337                        &mut identities,
338                        &mut capture.record_owners,
339                        id,
340                        "event",
341                        &event,
342                        owner,
343                    )?;
344                    events.insert(id, event);
345                }
346            }
347        }
348        // Annotation chains must not reveal a hidden edge through its UUID.
349        loop {
350            let before = hidden.len();
351            for (id, edge) in &all_edges {
352                if hidden.contains(&edge.source_id) || hidden.contains(&edge.target_id) {
353                    hidden.insert(*id);
354                }
355            }
356            if before == hidden.len() {
357                break;
358            }
359        }
360        for edge in all_edges.values().filter(|edge| edge.deleted_at.is_none()) {
361            let id = Uuid::from(edge.id);
362            if hidden.contains(&id) {
363                continue;
364            }
365            if edge
366                .target_backend
367                .as_ref()
368                .is_some_and(|name| !backend_aliases.contains(name))
369            {
370                return Err(crate::RuntimeError::Unconfigured(
371                    "lint edge endpoint backend is unavailable".into(),
372                ));
373            }
374            let resolved = |id| {
375                capture
376                    .all_entities
377                    .get(&id)
378                    .cloned()
379                    .map(crate::Resolved::Entity)
380                    .or_else(|| {
381                        capture
382                            .all_notes
383                            .get(&id)
384                            .cloned()
385                            .map(crate::Resolved::Note)
386                    })
387                    .or_else(|| events.get(&id).cloned().map(crate::Resolved::Event))
388            };
389            let live = |id| {
390                capture
391                    .all_entities
392                    .get(&id)
393                    .is_some_and(|record| record.deleted_at.is_none())
394                    || capture
395                        .all_notes
396                        .get(&id)
397                        .is_some_and(|record| record.deleted_at.is_none())
398                    || all_edges
399                        .get(&id)
400                        .is_some_and(|record| record.deleted_at.is_none())
401                    || events.contains_key(&id)
402            };
403            let deleted = |id| {
404                capture
405                    .all_entities
406                    .get(&id)
407                    .is_some_and(|record| record.deleted_at.is_some())
408                    || capture
409                        .all_notes
410                        .get(&id)
411                        .is_some_and(|record| record.deleted_at.is_some())
412                    || all_edges
413                        .get(&id)
414                        .is_some_and(|record| record.deleted_at.is_some())
415            };
416            let src = resolved(edge.source_id);
417            let tgt = resolved(edge.target_id);
418            let validator = &capture.owners[capture.record_owners[&id][0]];
419            let contract_valid = if edge.relation == khive_storage::EdgeRelation::Annotates
420                && all_edges.contains_key(&edge.target_id)
421            {
422                // The shared validator accepts annotates -> any resolved substrate;
423                // graph edges have no Resolved variant. Reuse its source/self-loop
424                // checks with the actual note source as the presence witness.
425                Some(
426                    validator
427                        .validate_link_endpoints_by_resolved_with_rules(
428                            edge.source_id,
429                            edge.target_id,
430                            edge.relation,
431                            src.as_ref(),
432                            src.as_ref(),
433                            &rules,
434                        )
435                        .is_ok(),
436                )
437            } else if (all_edges.contains_key(&edge.source_id)
438                && (tgt.is_some() || all_edges.contains_key(&edge.target_id)))
439                || (all_edges.contains_key(&edge.target_id) && src.is_some())
440            {
441                // Graph edges can only be annotation targets; the ordinary
442                // endpoint validator has no Resolved::Edge representation.
443                Some(false)
444            } else if src.is_some() && tgt.is_some() {
445                Some(
446                    validator
447                        .validate_link_endpoints_by_resolved_with_rules(
448                            edge.source_id,
449                            edge.target_id,
450                            edge.relation,
451                            src.as_ref(),
452                            tgt.as_ref(),
453                            &rules,
454                        )
455                        .is_ok(),
456                )
457            } else {
458                None
459            };
460            capture.edges.push(InspectedLintEdge {
461                edge: edge.clone(),
462                source_live: live(edge.source_id),
463                target_live: live(edge.target_id),
464                source_soft_deleted: deleted(edge.source_id),
465                target_soft_deleted: deleted(edge.target_id),
466                contract_valid,
467            });
468        }
469        capture.entities.sort_by_key(|record| record.id);
470        capture.notes.sort_by_key(|record| record.id);
471        capture
472            .edges
473            .sort_by_key(|record| Uuid::from(record.edge.id));
474        capture.all_edges = all_edges;
475        active()?;
476        Ok(capture)
477    }
478}