Skip to main content

khive_runtime/operations/
link_ops.rs

1use super::{
2    accepted_entity_relations_description, base_entity_rule_allows, canonical_edge_endpoint_kinds,
3    canonical_edge_endpoints, compose_graph_mutation_events, merge_dependency_kind,
4    pack_rule_allows, validate_edge_metadata, validate_edge_weight, CommitAnnotationGuard,
5    CommitAnnotationInsertOutcome, Edge, EdgeEndpointKind, EdgeRelation, EdgeUpsertDisposition,
6    EdgeUpsertRefusal, EdgeUpsertRequest, EdgeUpsertResult, Event, EventKind, GraphMutationOutcome,
7    GraphMutationPreconditions, GraphMutationRequest, GuardedEdgeUpsertOutcome,
8    GuardedWriteFailure, KhiveRuntime, LinkId, LinkSpec, NamespaceToken, Resolved, RuntimeError,
9    RuntimeResult, SubstrateKind, Uuid,
10};
11
12pub(super) fn guarded_link_batch_failure(
13    spec: &LinkSpec,
14    entry_index: usize,
15    missing: khive_storage::MissingEndpoints,
16) -> GuardedWriteFailure {
17    // Storage flags describe the canonical edge built from this spec, not the
18    // caller's potentially reversed spelling of a symmetric relation.
19    let (source_id, target_id) =
20        canonical_edge_endpoints(spec.relation, spec.source_id, spec.target_id);
21    GuardedWriteFailure {
22        entry_index: Some(entry_index),
23        missing_source: missing.source.then_some(source_id),
24        missing_target: missing.target.then_some(target_id),
25    }
26}
27
28impl KhiveRuntime {
29    // ---- Edge operations ----
30
31    /// Validate that `source_id` and `target_id` are legal endpoints for `relation`.
32    ///
33    /// Centralises the three-case relation contract so that both
34    /// `link()` and `update_edge()` share identical enforcement:
35    ///
36    /// - `annotates`: source MUST be a note; target may be any substrate.
37    /// - `supersedes` / `supports` / `refutes`: same-substrate only (note→note or entity→entity).
38    /// - All other relations: both endpoints MUST be entities.
39    ///
40    /// Returns the validated endpoint substrates when valid; otherwise
41    /// `InvalidInput` or `NotFound` for an invalid endpoint pair.
42    ///
43    /// `pub(crate)`: the atomic prepare pass (`crate::atomic_prepare`) reuses
44    /// this exact endpoint-type validation during its async prepare step,
45    /// before building a `LinkPlan`, rather than re-deriving the checks.
46    pub(crate) async fn validate_edge_relation_endpoints(
47        &self,
48        token: &NamespaceToken,
49        source_id: Uuid,
50        target_id: Uuid,
51        relation: EdgeRelation,
52    ) -> RuntimeResult<(EdgeEndpointKind, EdgeEndpointKind)> {
53        if source_id == target_id {
54            return Err(RuntimeError::InvalidInput(
55                "self-loop edges are not allowed: source_id and target_id must be different".into(),
56            ));
57        }
58        if relation == EdgeRelation::Annotates {
59            // Source must be a note. By-ID endpoint resolution is namespace-agnostic:
60            // link consumes two by-ID endpoints, so it must resolve exactly what
61            // get() resolves, regardless of caller namespace.
62            match self.resolve_edge_endpoint(token, source_id).await? {
63                Some(Resolved::Note(_)) => {}
64                Some(_) => {
65                    return Err(RuntimeError::InvalidInput(format!(
66                        "annotates source {source_id} must be a note"
67                    )));
68                }
69                None => {
70                    // Existing edge used as annotates source: wrong kind, not absent.
71                    if self.get_edge(token, source_id).await?.is_some() {
72                        return Err(RuntimeError::InvalidInput(format!(
73                            "annotates source {source_id} must be a note"
74                        )));
75                    }
76                    return Err(RuntimeError::NotFound(format!(
77                        "link source {source_id} not found"
78                    )));
79                }
80            }
81            // Target may be any substrate (entity, note, event, or edge) — by-ID, unfiltered.
82            let target_kind = match self.resolve_edge_endpoint(token, target_id).await? {
83                Some(Resolved::Entity(_)) => EdgeEndpointKind::Entity,
84                Some(Resolved::Note(_)) => EdgeEndpointKind::Note,
85                Some(Resolved::Event(_)) => EdgeEndpointKind::Event,
86                Some(Resolved::PackRecord { .. }) => {
87                    return Err(RuntimeError::InvalidInput(
88                        "pack-private record is not a valid edge endpoint for annotates".into(),
89                    ));
90                }
91                None => match self.get_edge(token, target_id).await {
92                    Ok(Some(_)) => EdgeEndpointKind::Edge,
93                    Ok(None) | Err(RuntimeError::NotFound(_)) => {
94                        return Err(RuntimeError::NotFound(format!(
95                            "link target {target_id} not found"
96                        )));
97                    }
98                    Err(error) => return Err(error),
99                },
100            };
101            return Ok((EdgeEndpointKind::Note, target_kind));
102        } else if crate::pack::is_special_relation(relation) {
103            // supersedes / supports / refutes: same-substrate only (note→note or entity→entity).
104            // Event and edge endpoints are invalid regardless of the other endpoint.
105            // Endpoint resolution is by-ID and namespace-agnostic.
106            let rel_name = relation.as_str();
107            let src = match self.resolve_edge_endpoint(token, source_id).await? {
108                Some(r) => r,
109                None => {
110                    if self.get_edge(token, source_id).await?.is_some() {
111                        return Err(RuntimeError::InvalidInput(format!(
112                            "{rel_name} source {source_id} must be a note or entity (got edge)"
113                        )));
114                    }
115                    return Err(RuntimeError::NotFound(format!(
116                        "link source {source_id} not found"
117                    )));
118                }
119            };
120            let tgt = match self.resolve_edge_endpoint(token, target_id).await? {
121                Some(r) => r,
122                None => {
123                    if self.get_edge(token, target_id).await?.is_some() {
124                        return Err(RuntimeError::InvalidInput(format!(
125                            "{rel_name} target {target_id} must be a note or entity (got edge)"
126                        )));
127                    }
128                    return Err(RuntimeError::NotFound(format!(
129                        "link target {target_id} not found"
130                    )));
131                }
132            };
133            return match (&src, &tgt) {
134                (Resolved::Entity(src_e), Resolved::Entity(tgt_e)) => {
135                    if !base_entity_rule_allows(&src_e.kind, relation, &tgt_e.kind) {
136                        let legal_relations = accepted_entity_relations_description(
137                            &self.pack_edge_rules(),
138                            &src_e.kind,
139                            src_e.entity_type.as_deref(),
140                            &tgt_e.kind,
141                            tgt_e.entity_type.as_deref(),
142                        );
143                        let rule_hint = match relation {
144                            EdgeRelation::Supports | EdgeRelation::Refutes => {
145                                "requires concept|document|dataset|artifact -> concept \
146                                 (or same-substrate note -> note)"
147                            }
148                            _ => "requires same-kind entity endpoints",
149                        };
150                        return Err(RuntimeError::InvalidInput(format!(
151                            "({}) -[{rel_name}]-> ({}) is not in the base endpoint \
152                             allowlist; {rel_name} {rule_hint}; currently legal relations for \
153                             {} -> {} under the loaded endpoint rules: {legal_relations}",
154                            src_e.kind, tgt_e.kind, src_e.kind, tgt_e.kind
155                        )));
156                    }
157                    Ok((EdgeEndpointKind::Entity, EdgeEndpointKind::Entity))
158                }
159                (Resolved::Note(_), Resolved::Note(_)) => {
160                    Ok((EdgeEndpointKind::Note, EdgeEndpointKind::Note))
161                }
162                (Resolved::Event(_), _) => {
163                    return Err(RuntimeError::InvalidInput(format!(
164                        "{rel_name} does not apply to events; source {source_id} is an event"
165                    )));
166                }
167                (_, Resolved::Event(_)) => {
168                    return Err(RuntimeError::InvalidInput(format!(
169                        "{rel_name} does not apply to events; target {target_id} is an event"
170                    )));
171                }
172                (Resolved::Entity(_), Resolved::Note(_)) => {
173                    return Err(RuntimeError::InvalidInput(format!(
174                        "{rel_name} endpoints must be the same substrate (note→note or entity→entity); \
175                         got source={source_id} (entity) target={target_id} (note)"
176                    )));
177                }
178                (Resolved::Note(_), Resolved::Entity(_)) => {
179                    return Err(RuntimeError::InvalidInput(format!(
180                        "{rel_name} endpoints must be the same substrate (note→note or entity→entity); \
181                         got source={source_id} (note) target={target_id} (entity)"
182                    )));
183                }
184                (Resolved::PackRecord { .. }, _) | (_, Resolved::PackRecord { .. }) => {
185                    return Err(RuntimeError::InvalidInput(format!(
186                        "pack-private record is not a valid edge endpoint for {rel_name}"
187                    )));
188                }
189            };
190        } else {
191            // All remaining base relations require entity→entity with kind-level
192            // restrictions (see base allowlist). Packs may extend the allowlist
193            // additively via EDGE_RULES.
194            //
195            // Strategy: resolve both endpoints once (by-ID, unfiltered), consult pack
196            // rules; on miss, fall through to the original base-rule error messages.
197            let src_res = self.resolve_edge_endpoint(token, source_id).await?;
198            let tgt_res = self.resolve_edge_endpoint(token, target_id).await?;
199            let pack_rules = self.pack_edge_rules();
200
201            if pack_rule_allows(&pack_rules, relation, src_res.as_ref(), tgt_res.as_ref()) {
202                let kind = |resolved: Option<&Resolved>| match resolved {
203                    Some(Resolved::Entity(_)) => Some(EdgeEndpointKind::Entity),
204                    Some(Resolved::Note(_)) => Some(EdgeEndpointKind::Note),
205                    _ => None,
206                };
207                return match (kind(src_res.as_ref()), kind(tgt_res.as_ref())) {
208                    (Some(source_kind), Some(target_kind)) => Ok((source_kind, target_kind)),
209                    _ => Err(RuntimeError::Internal(
210                        "pack endpoint rule admitted an unsupported substrate".into(),
211                    )),
212                };
213            }
214
215            // Substrate check: both endpoints must be entities.
216            let (src_kind, src_entity_type) = match src_res.as_ref() {
217                Some(Resolved::Entity(e)) => (e.kind.as_str(), e.entity_type.as_deref()),
218                Some(_) => {
219                    return Err(RuntimeError::InvalidInput(format!(
220                        "link source {source_id} must be an entity for relation {relation:?} \
221                         (only `annotates` crosses substrates)"
222                    )));
223                }
224                None => {
225                    if self.get_edge(token, source_id).await?.is_some() {
226                        return Err(RuntimeError::InvalidInput(format!(
227                            "link source {source_id} must be an entity for relation {relation:?} \
228                             (only `annotates` crosses substrates)"
229                        )));
230                    }
231                    return Err(RuntimeError::NotFound(format!(
232                        "link source {source_id} not found"
233                    )));
234                }
235            };
236            let (tgt_kind, tgt_entity_type) = match tgt_res.as_ref() {
237                Some(Resolved::Entity(e)) => (e.kind.as_str(), e.entity_type.as_deref()),
238                Some(_) => {
239                    return Err(RuntimeError::InvalidInput(format!(
240                        "link target {target_id} must be an entity for relation {relation:?} \
241                         (only `annotates` crosses substrates)"
242                    )));
243                }
244                None => {
245                    if self.get_edge(token, target_id).await?.is_some() {
246                        return Err(RuntimeError::InvalidInput(format!(
247                            "link target {target_id} must be an entity for relation {relation:?} \
248                             (only `annotates` crosses substrates)"
249                        )));
250                    }
251                    return Err(RuntimeError::NotFound(format!(
252                        "link target {target_id} not found"
253                    )));
254                }
255            };
256            if !base_entity_rule_allows(src_kind, relation, tgt_kind) {
257                let legal_relations = accepted_entity_relations_description(
258                    &pack_rules,
259                    src_kind,
260                    src_entity_type,
261                    tgt_kind,
262                    tgt_entity_type,
263                );
264                return Err(RuntimeError::InvalidInput(format!(
265                    "({src_kind}) -[{}]-> ({tgt_kind}) is not in the base endpoint \
266                     allowlist; use pack EDGE_RULES to extend the allowlist; currently legal \
267                     relations for {src_kind} -> {tgt_kind} under the loaded endpoint rules: \
268                     {legal_relations}",
269                    relation.as_str()
270                )));
271            }
272        }
273        Ok((EdgeEndpointKind::Entity, EdgeEndpointKind::Entity))
274    }
275
276    /// Public delegator for cross-backend link validation.
277    ///
278    /// Exposes `validate_edge_relation_endpoints` for the `SubstrateCoordinator`
279    /// so it can validate the relation before writing the edge on the source backend.
280    pub async fn validate_link_endpoints(
281        &self,
282        token: &NamespaceToken,
283        source_id: Uuid,
284        target_id: Uuid,
285        relation: EdgeRelation,
286    ) -> RuntimeResult<()> {
287        self.validate_edge_relation_endpoints(token, source_id, target_id, relation)
288            .await
289            .map(|_| ())
290    }
291
292    /// Validate an edge relation using pre-fetched endpoint records.
293    ///
294    /// For cross-backend links the source and target live on different backends —
295    /// the source runtime cannot resolve the target. The coordinator fetches each
296    /// endpoint from its own backend, then calls this method to enforce the
297    /// kind-pairing rules without a second DB round-trip.
298    ///
299    /// `src` and `tgt` are the `resolve_edge_endpoint` results from each backend. The
300    /// `token` supplies the pack edge rules installed on this (source) runtime;
301    /// no DB access is performed.
302    pub fn validate_link_endpoints_by_resolved(
303        &self,
304        source_id: Uuid,
305        target_id: Uuid,
306        relation: EdgeRelation,
307        src: Option<&Resolved>,
308        tgt: Option<&Resolved>,
309    ) -> RuntimeResult<()> {
310        if source_id == target_id {
311            return Err(RuntimeError::InvalidInput(
312                "self-loop edges are not allowed: source_id and target_id must be different".into(),
313            ));
314        }
315
316        if relation == EdgeRelation::Annotates {
317            match src {
318                Some(Resolved::Note(_)) => {}
319                Some(_) => {
320                    return Err(RuntimeError::InvalidInput(format!(
321                        "annotates source {source_id} must be a note"
322                    )));
323                }
324                None => {
325                    return Err(RuntimeError::NotFound(format!(
326                        "link source {source_id} not found"
327                    )));
328                }
329            }
330            if tgt.is_none() {
331                return Err(RuntimeError::NotFound(format!(
332                    "link target {target_id} not found"
333                )));
334            }
335            return Ok(());
336        }
337
338        if crate::pack::is_special_relation(relation) {
339            let rel_name = relation.as_str();
340            let src = src.ok_or_else(|| {
341                RuntimeError::NotFound(format!("link source {source_id} not found"))
342            })?;
343            let tgt = tgt.ok_or_else(|| {
344                RuntimeError::NotFound(format!("link target {target_id} not found"))
345            })?;
346            match (src, tgt) {
347                (Resolved::Entity(src_e), Resolved::Entity(tgt_e)) => {
348                    if !base_entity_rule_allows(&src_e.kind, relation, &tgt_e.kind) {
349                        let legal_relations = accepted_entity_relations_description(
350                            &self.pack_edge_rules(),
351                            &src_e.kind,
352                            src_e.entity_type.as_deref(),
353                            &tgt_e.kind,
354                            tgt_e.entity_type.as_deref(),
355                        );
356                        let rule_hint = match relation {
357                            EdgeRelation::Supports | EdgeRelation::Refutes => {
358                                "requires concept|document|dataset|artifact -> concept \
359                                 (or same-substrate note -> note)"
360                            }
361                            _ => "requires same-kind entity endpoints",
362                        };
363                        return Err(RuntimeError::InvalidInput(format!(
364                            "({}) -[{rel_name}]-> ({}) is not in the base endpoint \
365                             allowlist; {rel_name} {rule_hint}; currently legal relations for \
366                             {} -> {} under the loaded endpoint rules: {legal_relations}",
367                            src_e.kind, tgt_e.kind, src_e.kind, tgt_e.kind
368                        )));
369                    }
370                }
371                (Resolved::Note(_), Resolved::Note(_)) => {}
372                (Resolved::Entity(_), Resolved::Note(_)) => {
373                    return Err(RuntimeError::InvalidInput(format!(
374                        "{rel_name} endpoints must be the same substrate \
375                         (note→note or entity→entity); got source={source_id} (entity) \
376                         target={target_id} (note)"
377                    )));
378                }
379                (Resolved::Note(_), Resolved::Entity(_)) => {
380                    return Err(RuntimeError::InvalidInput(format!(
381                        "{rel_name} endpoints must be the same substrate \
382                         (note→note or entity→entity); got source={source_id} (note) \
383                         target={target_id} (entity)"
384                    )));
385                }
386                (Resolved::PackRecord { .. }, _) | (_, Resolved::PackRecord { .. }) => {
387                    return Err(RuntimeError::InvalidInput(format!(
388                        "pack-private record is not a valid edge endpoint for {rel_name}"
389                    )));
390                }
391                _ => {
392                    return Err(RuntimeError::InvalidInput(format!(
393                        "{rel_name} endpoints must be notes or entities (not events)"
394                    )));
395                }
396            }
397            return Ok(());
398        }
399
400        // All remaining base relations: entity→entity with kind-level restrictions.
401        // Consult pack rules installed on this (source) runtime first.
402        let pack_rules = self.pack_edge_rules();
403        if pack_rule_allows(&pack_rules, relation, src, tgt) {
404            return Ok(());
405        }
406
407        let (src_kind, src_entity_type) = match src {
408            Some(Resolved::Entity(e)) => (e.kind.as_str(), e.entity_type.as_deref()),
409            Some(_) => {
410                return Err(RuntimeError::InvalidInput(format!(
411                    "link source {source_id} must be an entity for relation {relation:?} \
412                     (only `annotates` crosses substrates)"
413                )));
414            }
415            None => {
416                return Err(RuntimeError::NotFound(format!(
417                    "link source {source_id} not found"
418                )));
419            }
420        };
421        let (tgt_kind, tgt_entity_type) = match tgt {
422            Some(Resolved::Entity(e)) => (e.kind.as_str(), e.entity_type.as_deref()),
423            Some(_) => {
424                return Err(RuntimeError::InvalidInput(format!(
425                    "link target {target_id} must be an entity for relation {relation:?} \
426                     (only `annotates` crosses substrates)"
427                )));
428            }
429            None => {
430                return Err(RuntimeError::NotFound(format!(
431                    "link target {target_id} not found"
432                )));
433            }
434        };
435
436        if !base_entity_rule_allows(src_kind, relation, tgt_kind) {
437            let legal_relations = accepted_entity_relations_description(
438                &pack_rules,
439                src_kind,
440                src_entity_type,
441                tgt_kind,
442                tgt_entity_type,
443            );
444            return Err(RuntimeError::InvalidInput(format!(
445                "({src_kind}) -[{}]-> ({tgt_kind}) is not in the base endpoint \
446                 allowlist; use pack EDGE_RULES to extend the allowlist; currently legal relations \
447                 for {src_kind} -> {tgt_kind} under the loaded endpoint rules: {legal_relations}",
448                relation.as_str()
449            )));
450        }
451
452        Ok(())
453    }
454
455    /// Validate an `annotates` edge relation using pre-located endpoint kinds.
456    ///
457    /// Sibling of [`Self::validate_link_endpoints_by_resolved`] for callers that
458    /// only have an [`EdgeEndpointKind`] (entity/note/event/edge) rather than a
459    /// full [`Resolved`] record — the `SubstrateCoordinator`'s cross-backend
460    /// `locate_endpoint` resolves edge-substrate UUIDs too (matching `get`'s
461    /// by-ID resolution order), but edges have no `Resolved` variant, so
462    /// `validate_link_endpoints_by_resolved` cannot express them.
463    ///
464    /// `annotates` is the only relation this covers: source must be a note,
465    /// target may be any substrate (entity, note, event, or edge).
466    pub fn validate_annotates_endpoint_kinds(
467        &self,
468        source_id: Uuid,
469        target_id: Uuid,
470        source: Option<EdgeEndpointKind>,
471        target: Option<EdgeEndpointKind>,
472    ) -> RuntimeResult<()> {
473        if source_id == target_id {
474            return Err(RuntimeError::InvalidInput(
475                "self-loop edges are not allowed: source_id and target_id must be different".into(),
476            ));
477        }
478        match source {
479            Some(EdgeEndpointKind::Note) => {}
480            Some(_) => {
481                return Err(RuntimeError::InvalidInput(format!(
482                    "annotates source {source_id} must be a note"
483                )));
484            }
485            None => {
486                return Err(RuntimeError::NotFound(format!(
487                    "link source {source_id} not found"
488                )));
489            }
490        }
491        if target.is_none() {
492            return Err(RuntimeError::NotFound(format!(
493                "link target {target_id} not found"
494            )));
495        }
496        Ok(())
497    }
498
499    /// Create a directed edge between two substrates.
500    ///
501    /// Enforces the three-case relation contract via
502    /// `validate_edge_relation_endpoints`. See that method for the full contract.
503    ///
504    /// For symmetric relations (`competes_with`, `composed_with`) the endpoint
505    /// pair is canonicalised to `source_uuid < target_uuid` so that A→B and B→A
506    /// deduplicate to one row.
507    ///
508    /// `metadata` is validated against governed keys; `dependency_kind` is
509    /// inferred for `depends_on` edges when absent.
510    ///
511    /// `target_backend` is always `None` for locally-routed edges written through
512    /// this path. Both endpoints must exist in the local namespace, so setting
513    /// `target_backend = None` is the only valid choice.
514    ///
515    /// Endpoint existence is a by-ID check and namespace-agnostic: a record
516    /// that exists in a different namespace than the caller still resolves,
517    /// exactly as `get()` would.
518    pub async fn link(
519        &self,
520        token: &NamespaceToken,
521        source_id: Uuid,
522        target_id: Uuid,
523        relation: EdgeRelation,
524        weight: f64,
525        metadata: Option<serde_json::Value>,
526    ) -> RuntimeResult<Edge> {
527        self.link_observed(
528            token, source_id, target_id, relation, weight, metadata, false,
529        )
530        .await
531        .map(|result| result.edge)
532    }
533
534    /// Observable form of [`Self::link`]. Live natural-key conflicts retain
535    /// the accepted replace semantics, while tombstones require the explicit
536    /// `resurrect` opt-in. The returned preimage and disposition are derived
537    /// inside the graph writer transaction and drive the lifecycle event.
538    #[allow(clippy::too_many_arguments)]
539    pub async fn link_observed(
540        &self,
541        token: &NamespaceToken,
542        source_id: Uuid,
543        target_id: Uuid,
544        relation: EdgeRelation,
545        weight: f64,
546        metadata: Option<serde_json::Value>,
547        resurrect: bool,
548    ) -> RuntimeResult<EdgeUpsertResult> {
549        validate_edge_weight(weight)?;
550        validate_edge_metadata(relation, metadata.as_ref())?;
551        let (source_kind, target_kind) = self
552            .validate_edge_relation_endpoints(token, source_id, target_id, relation)
553            .await?;
554        let (canonical_source, canonical_target) =
555            canonical_edge_endpoints(relation, source_id, target_id);
556        let (source_kind, target_kind) =
557            canonical_edge_endpoint_kinds(source_id, canonical_source, source_kind, target_kind);
558        let (source_id, target_id) = (canonical_source, canonical_target);
559        let metadata = if relation == EdgeRelation::DependsOn {
560            // By-ID, unfiltered — matches the namespace-agnostic endpoint validation
561            // above. The visible-set-scoped `resolve` would silently drop the
562            // dependency_kind inference for endpoints validation now allows outside
563            // the caller's visible set.
564            match (
565                self.resolve_edge_endpoint(token, source_id).await?,
566                self.resolve_edge_endpoint(token, target_id).await?,
567            ) {
568                (Some(Resolved::Entity(src_e)), Some(Resolved::Entity(tgt_e))) => {
569                    merge_dependency_kind(&src_e.kind, &tgt_e.kind, metadata)
570                }
571                _ => metadata,
572            }
573        } else {
574            metadata
575        };
576        validate_edge_metadata(relation, metadata.as_ref())?;
577        let now = chrono::Utc::now();
578        let ns = token.namespace().as_str();
579        let edge = Edge {
580            id: LinkId::from(Uuid::new_v4()),
581            namespace: ns.to_string(),
582            source_id,
583            target_id,
584            relation,
585            weight,
586            created_at: now,
587            updated_at: now,
588            deleted_at: None,
589            metadata,
590            target_backend: None,
591        };
592        // `upsert_edge_guarded` re-checks both endpoints exist as part of the same
593        // write, not the separate `validate_edge_relation_endpoints` read above: a
594        // concurrent hard-delete landing between that read and this write can no
595        // longer create a durably dangling edge. Which endpoint(s) were missing is
596        // reported by the guard's own in-transaction probe (`GuardedWriteOutcome::
597        // Refused`), not reconstructed here by re-reading the endpoints after the
598        // fact: a second concurrent write landing between the refusal and a
599        // post-hoc read could otherwise misreport which endpoint was actually
600        // missing at write time.
601        let attribution = crate::EventAttribution::from_token(token);
602        let outcome = compose_graph_mutation_events(
603            self.backend(),
604            GraphMutationRequest::Single {
605                request: EdgeUpsertRequest { edge, resurrect },
606                guard_endpoints: true,
607            },
608            GraphMutationPreconditions::default(),
609            Vec::new(),
610            move |outcome| match &outcome.mutation {
611                GraphMutationOutcome::Single(GuardedEdgeUpsertOutcome::Written(result)) => {
612                    Ok(vec![Self::link_mutation_event(
613                        &attribution,
614                        result,
615                        source_kind,
616                        target_kind,
617                    )])
618                }
619                _ => Err(Self::link_composition_shape_error(
620                    "expected a written singleton",
621                )),
622            },
623        )
624        .await?;
625        let GraphMutationOutcome::Single(outcome) = outcome.mutation else {
626            return Err(RuntimeError::Internal(
627                "link: unexpected composition outcome".into(),
628            ));
629        };
630        let result = match outcome {
631            GuardedEdgeUpsertOutcome::Written(result) => result,
632            GuardedEdgeUpsertOutcome::Refused(EdgeUpsertRefusal::MissingEndpoints(missing)) => {
633                return Err(RuntimeError::GuardedWriteFailed(GuardedWriteFailure {
634                    entry_index: None,
635                    missing_source: missing.source.then_some(source_id),
636                    missing_target: missing.target.then_some(target_id),
637                }));
638            }
639            GuardedEdgeUpsertOutcome::Refused(EdgeUpsertRefusal::ResurrectionRequired { edge }) => {
640                return Err(RuntimeError::InvalidInput(format!(
641                    "edge {} is soft-deleted; pass resurrect=true to link explicitly",
642                    edge.id
643                )))
644            }
645        };
646        Ok(result)
647    }
648
649    /// Write an edge with an explicit `target_backend` stamp (ADR-029 D3).
650    ///
651    /// Called by the `SubstrateCoordinator` when source and target are on
652    /// different backends. The coordinator validates endpoints before calling
653    /// this method via [`Self::validate_link_endpoints`], and supplies the
654    /// resolved endpoint kinds for the lifecycle event. Endpoint validation is
655    /// skipped here. The edge is written on the source backend only.
656    #[allow(clippy::too_many_arguments)]
657    pub async fn link_with_target_backend(
658        &self,
659        token: &NamespaceToken,
660        source_id: Uuid,
661        target_id: Uuid,
662        source_kind: EdgeEndpointKind,
663        target_kind: EdgeEndpointKind,
664        relation: EdgeRelation,
665        weight: f64,
666        metadata: Option<serde_json::Value>,
667        target_backend: Option<String>,
668    ) -> RuntimeResult<Edge> {
669        self.link_with_target_backend_observed(
670            token,
671            source_id,
672            target_id,
673            source_kind,
674            target_kind,
675            relation,
676            weight,
677            metadata,
678            target_backend,
679            false,
680        )
681        .await
682        .map(|result| result.edge)
683    }
684
685    /// Policy-aware cross-backend form of [`Self::link_observed`]. Endpoint
686    /// validation remains the coordinator's responsibility; mutation
687    /// classification and tombstone handling stay inside the source store.
688    #[allow(clippy::too_many_arguments)]
689    pub async fn link_with_target_backend_observed(
690        &self,
691        token: &NamespaceToken,
692        source_id: Uuid,
693        target_id: Uuid,
694        source_kind: EdgeEndpointKind,
695        target_kind: EdgeEndpointKind,
696        relation: EdgeRelation,
697        weight: f64,
698        metadata: Option<serde_json::Value>,
699        target_backend: Option<String>,
700        resurrect: bool,
701    ) -> RuntimeResult<EdgeUpsertResult> {
702        validate_edge_weight(weight)?;
703        let (canonical_source, canonical_target) =
704            canonical_edge_endpoints(relation, source_id, target_id);
705        let (source_kind, target_kind) =
706            canonical_edge_endpoint_kinds(source_id, canonical_source, source_kind, target_kind);
707        let (source_id, target_id) = (canonical_source, canonical_target);
708        validate_edge_metadata(relation, metadata.as_ref())?;
709        let now = chrono::Utc::now();
710        let ns = token.namespace().as_str();
711        let edge = Edge {
712            id: LinkId::from(Uuid::new_v4()),
713            namespace: ns.to_string(),
714            source_id,
715            target_id,
716            relation,
717            weight,
718            created_at: now,
719            updated_at: now,
720            deleted_at: None,
721            metadata,
722            target_backend,
723        };
724        let attribution = crate::EventAttribution::from_token(token);
725        let outcome = compose_graph_mutation_events(
726            self.backend(),
727            GraphMutationRequest::Single {
728                request: EdgeUpsertRequest { edge, resurrect },
729                guard_endpoints: false,
730            },
731            GraphMutationPreconditions::default(),
732            Vec::new(),
733            move |outcome| match &outcome.mutation {
734                GraphMutationOutcome::Single(GuardedEdgeUpsertOutcome::Written(result)) => {
735                    Ok(vec![Self::link_mutation_event(
736                        &attribution,
737                        result,
738                        source_kind,
739                        target_kind,
740                    )])
741                }
742                _ => Err(Self::link_composition_shape_error(
743                    "expected a written singleton",
744                )),
745            },
746        )
747        .await?;
748        match outcome.mutation {
749            GraphMutationOutcome::Single(GuardedEdgeUpsertOutcome::Written(result)) => Ok(result),
750            GraphMutationOutcome::Single(GuardedEdgeUpsertOutcome::Refused(
751                EdgeUpsertRefusal::ResurrectionRequired { edge },
752            )) => {
753                let error = khive_storage::StorageError::Conflict {
754                    capability: khive_storage::StorageCapability::Graph,
755                    operation: "upsert_edge_observed".into(),
756                    message: format!(
757                        "edge {} is soft-deleted; explicit resurrection is required",
758                        edge.id,
759                    ),
760                };
761                Err(RuntimeError::InvalidInput(format!(
762                    "edge natural key is soft-deleted; pass resurrect=true to link explicitly: {error}"
763                )))
764            }
765            _ => Err(RuntimeError::Internal(
766                "link: unexpected composition outcome".into(),
767            )),
768        }
769    }
770
771    fn link_mutation_event(
772        attribution: &crate::EventAttribution,
773        result: &EdgeUpsertResult,
774        source_kind: EdgeEndpointKind,
775        target_kind: EdgeEndpointKind,
776    ) -> Event {
777        let kind = match result.disposition {
778            EdgeUpsertDisposition::Created => EventKind::LinkCreated,
779            EdgeUpsertDisposition::Updated | EdgeUpsertDisposition::Resurrected => {
780                EventKind::EdgeUpdated
781            }
782        };
783        let edge_id = Uuid::from(result.edge.id);
784        let mut payload = serde_json::json!({
785            "id": edge_id,
786            "namespace": result.edge.namespace,
787            "mutation": result.disposition.name(),
788            "source_id": result.edge.source_id,
789            "target_id": result.edge.target_id,
790            "relation": result.edge.relation,
791            "weight": result.edge.weight,
792            "metadata": result.edge.metadata,
793            "previous": result.previous,
794        });
795        if kind == EventKind::LinkCreated {
796            payload["source_kind"] = serde_json::json!(source_kind.name());
797            payload["target_kind"] = serde_json::json!(target_kind.name());
798        }
799        attribution.stamp(
800            Event::new(
801                result.edge.namespace.clone(),
802                "link",
803                kind,
804                SubstrateKind::Entity,
805                "",
806            )
807            .with_target(edge_id)
808            .with_payload(payload),
809        )
810    }
811
812    fn link_composition_shape_error(message: &'static str) -> khive_storage::StorageError {
813        khive_storage::StorageError::InvalidInput {
814            capability: khive_storage::StorageCapability::Graph,
815            operation: "link_mutation_event".into(),
816            message: message.into(),
817        }
818    }
819
820    /// Validate and construct an edge from a [`LinkSpec`] without writing to storage.
821    ///
822    /// Applies the full edge contract (endpoint validation, symmetric
823    /// canonicalization, `dependency_kind` inference and metadata validation).
824    /// Returns the constructed `Edge` on success; the caller is responsible for
825    /// persisting it (e.g. via `upsert_edge` or `link_many`).
826    ///
827    /// The `token` must be a pre-authorized namespace token from the dispatch
828    /// layer. If `spec.namespace` is set it must match `token.namespace()`;
829    /// a mismatch returns `RuntimeError::InvalidInput`.
830    pub async fn build_edge(&self, token: &NamespaceToken, spec: &LinkSpec) -> RuntimeResult<Edge> {
831        self.build_edge_with_endpoint_kinds(token, spec)
832            .await
833            .map(|(edge, _)| edge)
834    }
835
836    async fn build_edge_with_endpoint_kinds(
837        &self,
838        token: &NamespaceToken,
839        spec: &LinkSpec,
840    ) -> RuntimeResult<(Edge, (EdgeEndpointKind, EdgeEndpointKind))> {
841        validate_edge_metadata(spec.relation, spec.metadata.as_ref())?;
842        let ns_str = match &spec.namespace {
843            Some(s) => {
844                let spec_ns = crate::Namespace::parse(s)
845                    .map_err(|e| RuntimeError::InvalidInput(format!("invalid namespace: {e}")))?;
846                if &spec_ns != token.namespace() {
847                    return Err(RuntimeError::InvalidInput(
848                        "LinkSpec namespace does not match token namespace".into(),
849                    ));
850                }
851                s.as_str()
852            }
853            None => token.namespace().as_str(),
854        };
855        let endpoint_kinds = self
856            .validate_edge_relation_endpoints(token, spec.source_id, spec.target_id, spec.relation)
857            .await?;
858        let (source_id, target_id) =
859            canonical_edge_endpoints(spec.relation, spec.source_id, spec.target_id);
860        let endpoint_kinds = canonical_edge_endpoint_kinds(
861            spec.source_id,
862            source_id,
863            endpoint_kinds.0,
864            endpoint_kinds.1,
865        );
866        let metadata = if spec.relation == EdgeRelation::DependsOn {
867            // By-ID, unfiltered — matches the namespace-agnostic endpoint validation
868            // above. The visible-set-scoped `resolve` would silently drop the
869            // dependency_kind inference for endpoints validation now allows outside
870            // the caller's visible set.
871            match (
872                self.resolve_edge_endpoint(token, source_id).await?,
873                self.resolve_edge_endpoint(token, target_id).await?,
874            ) {
875                (Some(Resolved::Entity(src_e)), Some(Resolved::Entity(tgt_e))) => {
876                    merge_dependency_kind(&src_e.kind, &tgt_e.kind, spec.metadata.clone())
877                }
878                _ => spec.metadata.clone(),
879            }
880        } else {
881            spec.metadata.clone()
882        };
883        validate_edge_metadata(spec.relation, metadata.as_ref())?;
884        let now = chrono::Utc::now();
885        Ok((
886            Edge {
887                id: LinkId::from(Uuid::new_v4()),
888                namespace: ns_str.to_string(),
889                source_id,
890                target_id,
891                relation: spec.relation,
892                weight: spec.weight,
893                created_at: now,
894                updated_at: now,
895                deleted_at: None,
896                metadata,
897                target_backend: None,
898            },
899            endpoint_kinds,
900        ))
901    }
902
903    /// Validate and atomically upsert a batch of edges.
904    ///
905    /// All edges are validated and constructed with `build_edge` before any
906    /// write. If validation fails for any entry the entire batch is rejected
907    /// (no writes occur). On success, all edges are persisted in a single
908    /// source transaction with every lifecycle event and observation projection.
909    ///
910    /// After the bulk upsert, each edge is read back by its natural key
911    /// (namespace, source_id, target_id, relation) so that the returned IDs
912    /// are always the persisted row IDs, not the locally-generated UUIDs that
913    /// may have been displaced by an ON CONFLICT DO UPDATE. This mirrors the
914    /// same read-back applied to singleton `link()` and prevents phantom-ID
915    /// exposure when callers upsert overlapping triples with `verbose=true`.
916    ///
917    /// All specs must share the same namespace; the namespace is taken from
918    /// `token` (or validated against it if `spec.namespace` is set).
919    pub async fn link_many(
920        &self,
921        token: &NamespaceToken,
922        specs: Vec<LinkSpec>,
923    ) -> RuntimeResult<Vec<Edge>> {
924        self.link_many_observed(token, specs)
925            .await
926            .map(|rows| rows.into_iter().map(|row| row.edge).collect())
927    }
928
929    /// Observed all-or-nothing bulk link upsert. Every row carries its own
930    /// create/update/resurrection disposition, and every tombstone policy is
931    /// preflighted inside the same writer transaction before any mutation.
932    pub async fn link_many_observed(
933        &self,
934        token: &NamespaceToken,
935        specs: Vec<LinkSpec>,
936    ) -> RuntimeResult<Vec<EdgeUpsertResult>> {
937        self.link_many_guarded_observed(
938            token,
939            specs,
940            GraphMutationPreconditions::default(),
941            Vec::new(),
942        )
943        .await
944        .map(|(rows, _)| rows)
945    }
946
947    /// Internal seam for khive-runtime; no compatibility promise.
948    ///
949    /// Guarded composition seam for packs.
950    #[doc(hidden)]
951    pub async fn link_many_guarded_observed(
952        &self,
953        token: &NamespaceToken,
954        specs: Vec<LinkSpec>,
955        preconditions: GraphMutationPreconditions,
956        retirements: Vec<Edge>,
957    ) -> RuntimeResult<(Vec<EdgeUpsertResult>, Vec<LinkId>)> {
958        let namespace = token.namespace().as_str();
959        let foreign_document = preconditions
960            .document
961            .as_ref()
962            .is_some_and(|guard| guard.namespace != namespace);
963        let foreign_edge = preconditions.edges.iter().any(|guard| {
964            guard.namespace != namespace
965                || guard
966                    .expected
967                    .as_ref()
968                    .is_some_and(|edge| edge.namespace != namespace)
969        });
970        if foreign_document
971            || foreign_edge
972            || retirements.iter().any(|edge| edge.namespace != namespace)
973        {
974            return Err(RuntimeError::InvalidInput(
975                "guarded link namespace does not match token namespace".into(),
976            ));
977        }
978        if specs.is_empty()
979            && preconditions.document.is_none()
980            && preconditions.edges.is_empty()
981            && retirements.is_empty()
982        {
983            return Ok((Vec::new(), Vec::new()));
984        }
985        let mut edges = Vec::with_capacity(specs.len());
986        let mut endpoint_kinds = Vec::with_capacity(specs.len());
987        for spec in &specs {
988            let (edge, kinds) = self.build_edge_with_endpoint_kinds(token, spec).await?;
989            edges.push(edge);
990            endpoint_kinds.push(kinds);
991        }
992        // `upsert_edges_guarded` re-checks every edge's endpoints as part of the
993        // same write, not the separate per-spec `build_edge` validation reads
994        // above. A concurrent hard-delete of any endpoint landing between those
995        // reads and this write aborts the whole batch (all-or-nothing, no
996        // partial write) instead of persisting a dangling edge. The failing
997        // entry's index and its missing endpoint(s) come from the guard's own
998        // in-transaction pre-check (`GuardedBatchOutcome::refused`), not a
999        // post-hoc re-read of the batch after the write already failed.
1000        let requests = edges
1001            .into_iter()
1002            .zip(specs.iter())
1003            .map(|(edge, spec)| EdgeUpsertRequest {
1004                edge,
1005                resurrect: spec.resurrect,
1006            })
1007            .collect();
1008        let attribution = crate::EventAttribution::from_token(token);
1009        let outcome = compose_graph_mutation_events(
1010            self.backend(),
1011            GraphMutationRequest::Batch {
1012                requests,
1013                guard_endpoints: true,
1014            },
1015            preconditions,
1016            retirements,
1017            move |outcome| {
1018                let GraphMutationOutcome::Batch(batch) = &outcome.mutation else {
1019                    return Err(Self::link_composition_shape_error(
1020                        "expected a written batch",
1021                    ));
1022                };
1023                if batch.rows.len() != endpoint_kinds.len() {
1024                    return Err(Self::link_composition_shape_error(
1025                        "edge result count differs from validated endpoint count",
1026                    ));
1027                }
1028                let mut events = Vec::with_capacity(batch.rows.len() + outcome.retired.len());
1029                for (row, (source_kind, target_kind)) in batch.rows.iter().zip(endpoint_kinds) {
1030                    events.push(Self::link_mutation_event(
1031                        &attribution,
1032                        row,
1033                        source_kind,
1034                        target_kind,
1035                    ));
1036                }
1037                for edge in &outcome.retired {
1038                    let edge_id = Uuid::from(edge.id);
1039                    events.push(
1040                        attribution.stamp(
1041                            Event::new(
1042                                edge.namespace.clone(),
1043                                "delete",
1044                                EventKind::EdgeDeleted,
1045                                SubstrateKind::Entity,
1046                                "",
1047                            )
1048                            .with_target(edge_id)
1049                            .with_payload(serde_json::json!({
1050                                "id": edge_id, "namespace": edge.namespace, "hard": false,
1051                            })),
1052                        ),
1053                    );
1054                }
1055                Ok(events)
1056            },
1057        )
1058        .await?;
1059        let retired = outcome.retired.into_iter().map(|edge| edge.id).collect();
1060        let GraphMutationOutcome::Batch(outcome) = outcome.mutation else {
1061            return Err(RuntimeError::Internal(
1062                "link_many: unexpected composition outcome".into(),
1063            ));
1064        };
1065        if let Some(refusal) = outcome.refusal {
1066            return match refusal.reason {
1067                EdgeUpsertRefusal::MissingEndpoints(missing) => {
1068                    Err(RuntimeError::GuardedWriteFailed(guarded_link_batch_failure(
1069                        &specs[refusal.entry_index],
1070                        refusal.entry_index,
1071                        missing,
1072                    )))
1073                }
1074                EdgeUpsertRefusal::ResurrectionRequired { edge } => {
1075                    Err(RuntimeError::InvalidInput(format!(
1076                        "batch entry {} targets soft-deleted edge {}; pass resurrect=true for that link",
1077                        refusal.entry_index, edge.id
1078                    )))
1079                }
1080            };
1081        }
1082        Ok((outcome.rows, retired))
1083    }
1084
1085    /// Create a historical commit-to-project annotation without replacing a
1086    /// curated edge or reviving a tombstone. The store rechecks the exact live
1087    /// commit SHA and project under its writer transaction; only a newly
1088    /// inserted edge produces the ordinary LinkCreated lifecycle event.
1089    pub async fn link_commit_annotation_if_absent(
1090        &self,
1091        token: &NamespaceToken,
1092        commit_id: Uuid,
1093        project_id: Uuid,
1094        guard: CommitAnnotationGuard,
1095    ) -> RuntimeResult<CommitAnnotationInsertOutcome> {
1096        if !matches!(guard.expected_sha.len(), 40 | 64)
1097            || !guard
1098                .expected_sha
1099                .bytes()
1100                .all(|byte| byte.is_ascii_hexdigit())
1101        {
1102            return Err(RuntimeError::InvalidInput(
1103                "expected full commit SHA".into(),
1104            ));
1105        }
1106        let edge = self
1107            .build_edge(
1108                token,
1109                &LinkSpec {
1110                    namespace: None,
1111                    source_id: commit_id,
1112                    target_id: project_id,
1113                    relation: EdgeRelation::Annotates,
1114                    weight: 1.0,
1115                    metadata: None,
1116                    resurrect: false,
1117                },
1118            )
1119            .await?;
1120        let attribution = crate::EventAttribution::from_token(token);
1121        let outcome = compose_graph_mutation_events(
1122            self.backend(),
1123            GraphMutationRequest::CommitAnnotation { edge, guard },
1124            GraphMutationPreconditions::default(),
1125            Vec::new(),
1126            move |outcome| match &outcome.mutation {
1127                GraphMutationOutcome::CommitAnnotation(CommitAnnotationInsertOutcome::Created(
1128                    edge,
1129                )) => Ok(vec![Self::link_mutation_event(
1130                    &attribution,
1131                    &EdgeUpsertResult {
1132                        edge: edge.clone(),
1133                        disposition: EdgeUpsertDisposition::Created,
1134                        previous: None,
1135                    },
1136                    EdgeEndpointKind::Note,
1137                    EdgeEndpointKind::Entity,
1138                )]),
1139                _ => Err(Self::link_composition_shape_error(
1140                    "expected a created annotation",
1141                )),
1142            },
1143        )
1144        .await?;
1145        let GraphMutationOutcome::CommitAnnotation(result) = outcome.mutation else {
1146            return Err(RuntimeError::Internal(
1147                "link annotation: unexpected composition outcome".into(),
1148            ));
1149        };
1150        Ok(result)
1151    }
1152}