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        self.validate_link_endpoints_by_resolved_with_rules(
311            source_id,
312            target_id,
313            relation,
314            src,
315            tgt,
316            &self.pack_edge_rules(),
317        )
318    }
319
320    pub(crate) fn validate_link_endpoints_by_resolved_with_rules(
321        &self,
322        source_id: Uuid,
323        target_id: Uuid,
324        relation: EdgeRelation,
325        src: Option<&Resolved>,
326        tgt: Option<&Resolved>,
327        pack_rules: &[khive_types::EdgeEndpointRule],
328    ) -> RuntimeResult<()> {
329        if source_id == target_id {
330            return Err(RuntimeError::InvalidInput(
331                "self-loop edges are not allowed: source_id and target_id must be different".into(),
332            ));
333        }
334
335        if relation == EdgeRelation::Annotates {
336            match src {
337                Some(Resolved::Note(_)) => {}
338                Some(_) => {
339                    return Err(RuntimeError::InvalidInput(format!(
340                        "annotates source {source_id} must be a note"
341                    )));
342                }
343                None => {
344                    return Err(RuntimeError::NotFound(format!(
345                        "link source {source_id} not found"
346                    )));
347                }
348            }
349            if tgt.is_none() {
350                return Err(RuntimeError::NotFound(format!(
351                    "link target {target_id} not found"
352                )));
353            }
354            return Ok(());
355        }
356
357        if crate::pack::is_special_relation(relation) {
358            let rel_name = relation.as_str();
359            let src = src.ok_or_else(|| {
360                RuntimeError::NotFound(format!("link source {source_id} not found"))
361            })?;
362            let tgt = tgt.ok_or_else(|| {
363                RuntimeError::NotFound(format!("link target {target_id} not found"))
364            })?;
365            match (src, tgt) {
366                (Resolved::Entity(src_e), Resolved::Entity(tgt_e)) => {
367                    if !base_entity_rule_allows(&src_e.kind, relation, &tgt_e.kind) {
368                        let legal_relations = accepted_entity_relations_description(
369                            pack_rules,
370                            &src_e.kind,
371                            src_e.entity_type.as_deref(),
372                            &tgt_e.kind,
373                            tgt_e.entity_type.as_deref(),
374                        );
375                        let rule_hint = match relation {
376                            EdgeRelation::Supports | EdgeRelation::Refutes => {
377                                "requires concept|document|dataset|artifact -> concept \
378                                 (or same-substrate note -> note)"
379                            }
380                            _ => "requires same-kind entity endpoints",
381                        };
382                        return Err(RuntimeError::InvalidInput(format!(
383                            "({}) -[{rel_name}]-> ({}) is not in the base endpoint \
384                             allowlist; {rel_name} {rule_hint}; currently legal relations for \
385                             {} -> {} under the loaded endpoint rules: {legal_relations}",
386                            src_e.kind, tgt_e.kind, src_e.kind, tgt_e.kind
387                        )));
388                    }
389                }
390                (Resolved::Note(_), Resolved::Note(_)) => {}
391                (Resolved::Entity(_), Resolved::Note(_)) => {
392                    return Err(RuntimeError::InvalidInput(format!(
393                        "{rel_name} endpoints must be the same substrate \
394                         (note→note or entity→entity); got source={source_id} (entity) \
395                         target={target_id} (note)"
396                    )));
397                }
398                (Resolved::Note(_), Resolved::Entity(_)) => {
399                    return Err(RuntimeError::InvalidInput(format!(
400                        "{rel_name} endpoints must be the same substrate \
401                         (note→note or entity→entity); got source={source_id} (note) \
402                         target={target_id} (entity)"
403                    )));
404                }
405                (Resolved::PackRecord { .. }, _) | (_, Resolved::PackRecord { .. }) => {
406                    return Err(RuntimeError::InvalidInput(format!(
407                        "pack-private record is not a valid edge endpoint for {rel_name}"
408                    )));
409                }
410                _ => {
411                    return Err(RuntimeError::InvalidInput(format!(
412                        "{rel_name} endpoints must be notes or entities (not events)"
413                    )));
414                }
415            }
416            return Ok(());
417        }
418
419        // All remaining base relations: entity→entity with kind-level restrictions.
420        // Consult pack rules installed on this (source) runtime first.
421        if pack_rule_allows(pack_rules, relation, src, tgt) {
422            return Ok(());
423        }
424
425        let (src_kind, src_entity_type) = match src {
426            Some(Resolved::Entity(e)) => (e.kind.as_str(), e.entity_type.as_deref()),
427            Some(_) => {
428                return Err(RuntimeError::InvalidInput(format!(
429                    "link source {source_id} must be an entity for relation {relation:?} \
430                     (only `annotates` crosses substrates)"
431                )));
432            }
433            None => {
434                return Err(RuntimeError::NotFound(format!(
435                    "link source {source_id} not found"
436                )));
437            }
438        };
439        let (tgt_kind, tgt_entity_type) = match tgt {
440            Some(Resolved::Entity(e)) => (e.kind.as_str(), e.entity_type.as_deref()),
441            Some(_) => {
442                return Err(RuntimeError::InvalidInput(format!(
443                    "link target {target_id} must be an entity for relation {relation:?} \
444                     (only `annotates` crosses substrates)"
445                )));
446            }
447            None => {
448                return Err(RuntimeError::NotFound(format!(
449                    "link target {target_id} not found"
450                )));
451            }
452        };
453
454        if !base_entity_rule_allows(src_kind, relation, tgt_kind) {
455            let legal_relations = accepted_entity_relations_description(
456                pack_rules,
457                src_kind,
458                src_entity_type,
459                tgt_kind,
460                tgt_entity_type,
461            );
462            return Err(RuntimeError::InvalidInput(format!(
463                "({src_kind}) -[{}]-> ({tgt_kind}) is not in the base endpoint \
464                 allowlist; use pack EDGE_RULES to extend the allowlist; currently legal relations \
465                 for {src_kind} -> {tgt_kind} under the loaded endpoint rules: {legal_relations}",
466                relation.as_str()
467            )));
468        }
469
470        Ok(())
471    }
472
473    /// Validate an `annotates` edge relation using pre-located endpoint kinds.
474    ///
475    /// Sibling of [`Self::validate_link_endpoints_by_resolved`] for callers that
476    /// only have an [`EdgeEndpointKind`] (entity/note/event/edge) rather than a
477    /// full [`Resolved`] record — the `SubstrateCoordinator`'s cross-backend
478    /// `locate_endpoint` resolves edge-substrate UUIDs too (matching `get`'s
479    /// by-ID resolution order), but edges have no `Resolved` variant, so
480    /// `validate_link_endpoints_by_resolved` cannot express them.
481    ///
482    /// `annotates` is the only relation this covers: source must be a note,
483    /// target may be any substrate (entity, note, event, or edge).
484    pub fn validate_annotates_endpoint_kinds(
485        &self,
486        source_id: Uuid,
487        target_id: Uuid,
488        source: Option<EdgeEndpointKind>,
489        target: Option<EdgeEndpointKind>,
490    ) -> RuntimeResult<()> {
491        if source_id == target_id {
492            return Err(RuntimeError::InvalidInput(
493                "self-loop edges are not allowed: source_id and target_id must be different".into(),
494            ));
495        }
496        match source {
497            Some(EdgeEndpointKind::Note) => {}
498            Some(_) => {
499                return Err(RuntimeError::InvalidInput(format!(
500                    "annotates source {source_id} must be a note"
501                )));
502            }
503            None => {
504                return Err(RuntimeError::NotFound(format!(
505                    "link source {source_id} not found"
506                )));
507            }
508        }
509        if target.is_none() {
510            return Err(RuntimeError::NotFound(format!(
511                "link target {target_id} not found"
512            )));
513        }
514        Ok(())
515    }
516
517    /// Create a directed edge between two substrates.
518    ///
519    /// Enforces the three-case relation contract via
520    /// `validate_edge_relation_endpoints`. See that method for the full contract.
521    ///
522    /// For symmetric relations (`competes_with`, `composed_with`) the endpoint
523    /// pair is canonicalised to `source_uuid < target_uuid` so that A→B and B→A
524    /// deduplicate to one row.
525    ///
526    /// `metadata` is validated against governed keys; `dependency_kind` is
527    /// inferred for `depends_on` edges when absent.
528    ///
529    /// `target_backend` is always `None` for locally-routed edges written through
530    /// this path. Both endpoints must exist in the local namespace, so setting
531    /// `target_backend = None` is the only valid choice.
532    ///
533    /// Endpoint existence is a by-ID check and namespace-agnostic: a record
534    /// that exists in a different namespace than the caller still resolves,
535    /// exactly as `get()` would.
536    pub async fn link(
537        &self,
538        token: &NamespaceToken,
539        source_id: Uuid,
540        target_id: Uuid,
541        relation: EdgeRelation,
542        weight: f64,
543        metadata: Option<serde_json::Value>,
544    ) -> RuntimeResult<Edge> {
545        self.link_observed(
546            token, source_id, target_id, relation, weight, metadata, false,
547        )
548        .await
549        .map(|result| result.edge)
550    }
551
552    /// Observable form of [`Self::link`]. Live natural-key conflicts retain
553    /// the accepted replace semantics, while tombstones require the explicit
554    /// `resurrect` opt-in. The returned preimage and disposition are derived
555    /// inside the graph writer transaction and drive the lifecycle event.
556    #[allow(clippy::too_many_arguments)]
557    pub async fn link_observed(
558        &self,
559        token: &NamespaceToken,
560        source_id: Uuid,
561        target_id: Uuid,
562        relation: EdgeRelation,
563        weight: f64,
564        metadata: Option<serde_json::Value>,
565        resurrect: bool,
566    ) -> RuntimeResult<EdgeUpsertResult> {
567        validate_edge_weight(weight)?;
568        validate_edge_metadata(relation, metadata.as_ref())?;
569        let (source_kind, target_kind) = self
570            .validate_edge_relation_endpoints(token, source_id, target_id, relation)
571            .await?;
572        let (canonical_source, canonical_target) =
573            canonical_edge_endpoints(relation, source_id, target_id);
574        let (source_kind, target_kind) =
575            canonical_edge_endpoint_kinds(source_id, canonical_source, source_kind, target_kind);
576        let (source_id, target_id) = (canonical_source, canonical_target);
577        let metadata = if relation == EdgeRelation::DependsOn {
578            // By-ID, unfiltered — matches the namespace-agnostic endpoint validation
579            // above. The visible-set-scoped `resolve` would silently drop the
580            // dependency_kind inference for endpoints validation now allows outside
581            // the caller's visible set.
582            match (
583                self.resolve_edge_endpoint(token, source_id).await?,
584                self.resolve_edge_endpoint(token, target_id).await?,
585            ) {
586                (Some(Resolved::Entity(src_e)), Some(Resolved::Entity(tgt_e))) => {
587                    merge_dependency_kind(&src_e.kind, &tgt_e.kind, metadata)
588                }
589                _ => metadata,
590            }
591        } else {
592            metadata
593        };
594        validate_edge_metadata(relation, metadata.as_ref())?;
595        let now = chrono::Utc::now();
596        let ns = token.namespace().as_str();
597        let edge = Edge {
598            id: LinkId::from(Uuid::new_v4()),
599            namespace: ns.to_string(),
600            source_id,
601            target_id,
602            relation,
603            weight,
604            created_at: now,
605            updated_at: now,
606            deleted_at: None,
607            metadata,
608            target_backend: None,
609        };
610        // `upsert_edge_guarded` re-checks both endpoints exist as part of the same
611        // write, not the separate `validate_edge_relation_endpoints` read above: a
612        // concurrent hard-delete landing between that read and this write can no
613        // longer create a durably dangling edge. Which endpoint(s) were missing is
614        // reported by the guard's own in-transaction probe (`GuardedWriteOutcome::
615        // Refused`), not reconstructed here by re-reading the endpoints after the
616        // fact: a second concurrent write landing between the refusal and a
617        // post-hoc read could otherwise misreport which endpoint was actually
618        // missing at write time.
619        let attribution = crate::EventAttribution::from_token(token);
620        let outcome = compose_graph_mutation_events(
621            self.backend(),
622            GraphMutationRequest::Single {
623                request: EdgeUpsertRequest { edge, resurrect },
624                guard_endpoints: true,
625            },
626            GraphMutationPreconditions::default(),
627            Vec::new(),
628            move |outcome| match &outcome.mutation {
629                GraphMutationOutcome::Single(GuardedEdgeUpsertOutcome::Written(result)) => {
630                    Ok(vec![Self::link_mutation_event(
631                        &attribution,
632                        result,
633                        source_kind,
634                        target_kind,
635                    )])
636                }
637                _ => Err(Self::link_composition_shape_error(
638                    "expected a written singleton",
639                )),
640            },
641        )
642        .await?;
643        let GraphMutationOutcome::Single(outcome) = outcome.mutation else {
644            return Err(RuntimeError::Internal(
645                "link: unexpected composition outcome".into(),
646            ));
647        };
648        let result = match outcome {
649            GuardedEdgeUpsertOutcome::Written(result) => result,
650            GuardedEdgeUpsertOutcome::Refused(EdgeUpsertRefusal::MissingEndpoints(missing)) => {
651                return Err(RuntimeError::GuardedWriteFailed(GuardedWriteFailure {
652                    entry_index: None,
653                    missing_source: missing.source.then_some(source_id),
654                    missing_target: missing.target.then_some(target_id),
655                }));
656            }
657            GuardedEdgeUpsertOutcome::Refused(EdgeUpsertRefusal::ResurrectionRequired { edge }) => {
658                return Err(RuntimeError::InvalidInput(format!(
659                    "edge {} is soft-deleted; pass resurrect=true to link explicitly",
660                    edge.id
661                )))
662            }
663        };
664        Ok(result)
665    }
666
667    /// Write an edge with an explicit `target_backend` stamp (ADR-029 D3).
668    ///
669    /// Called by the `SubstrateCoordinator` when source and target are on
670    /// different backends. The coordinator validates endpoints before calling
671    /// this method via [`Self::validate_link_endpoints`], and supplies the
672    /// resolved endpoint kinds for the lifecycle event. Endpoint validation is
673    /// skipped here. The edge is written on the source backend only.
674    #[allow(clippy::too_many_arguments)]
675    pub async fn link_with_target_backend(
676        &self,
677        token: &NamespaceToken,
678        source_id: Uuid,
679        target_id: Uuid,
680        source_kind: EdgeEndpointKind,
681        target_kind: EdgeEndpointKind,
682        relation: EdgeRelation,
683        weight: f64,
684        metadata: Option<serde_json::Value>,
685        target_backend: Option<String>,
686    ) -> RuntimeResult<Edge> {
687        self.link_with_target_backend_observed(
688            token,
689            source_id,
690            target_id,
691            source_kind,
692            target_kind,
693            relation,
694            weight,
695            metadata,
696            target_backend,
697            false,
698        )
699        .await
700        .map(|result| result.edge)
701    }
702
703    /// Policy-aware cross-backend form of [`Self::link_observed`]. Endpoint
704    /// validation remains the coordinator's responsibility; mutation
705    /// classification and tombstone handling stay inside the source store.
706    #[allow(clippy::too_many_arguments)]
707    pub async fn link_with_target_backend_observed(
708        &self,
709        token: &NamespaceToken,
710        source_id: Uuid,
711        target_id: Uuid,
712        source_kind: EdgeEndpointKind,
713        target_kind: EdgeEndpointKind,
714        relation: EdgeRelation,
715        weight: f64,
716        metadata: Option<serde_json::Value>,
717        target_backend: Option<String>,
718        resurrect: bool,
719    ) -> RuntimeResult<EdgeUpsertResult> {
720        validate_edge_weight(weight)?;
721        let (canonical_source, canonical_target) =
722            canonical_edge_endpoints(relation, source_id, target_id);
723        let (source_kind, target_kind) =
724            canonical_edge_endpoint_kinds(source_id, canonical_source, source_kind, target_kind);
725        let (source_id, target_id) = (canonical_source, canonical_target);
726        validate_edge_metadata(relation, metadata.as_ref())?;
727        let now = chrono::Utc::now();
728        let ns = token.namespace().as_str();
729        let edge = Edge {
730            id: LinkId::from(Uuid::new_v4()),
731            namespace: ns.to_string(),
732            source_id,
733            target_id,
734            relation,
735            weight,
736            created_at: now,
737            updated_at: now,
738            deleted_at: None,
739            metadata,
740            target_backend,
741        };
742        let attribution = crate::EventAttribution::from_token(token);
743        let outcome = compose_graph_mutation_events(
744            self.backend(),
745            GraphMutationRequest::Single {
746                request: EdgeUpsertRequest { edge, resurrect },
747                guard_endpoints: false,
748            },
749            GraphMutationPreconditions::default(),
750            Vec::new(),
751            move |outcome| match &outcome.mutation {
752                GraphMutationOutcome::Single(GuardedEdgeUpsertOutcome::Written(result)) => {
753                    Ok(vec![Self::link_mutation_event(
754                        &attribution,
755                        result,
756                        source_kind,
757                        target_kind,
758                    )])
759                }
760                _ => Err(Self::link_composition_shape_error(
761                    "expected a written singleton",
762                )),
763            },
764        )
765        .await?;
766        match outcome.mutation {
767            GraphMutationOutcome::Single(GuardedEdgeUpsertOutcome::Written(result)) => Ok(result),
768            GraphMutationOutcome::Single(GuardedEdgeUpsertOutcome::Refused(
769                EdgeUpsertRefusal::ResurrectionRequired { edge },
770            )) => {
771                let error = khive_storage::StorageError::Conflict {
772                    capability: khive_storage::StorageCapability::Graph,
773                    operation: "upsert_edge_observed".into(),
774                    message: format!(
775                        "edge {} is soft-deleted; explicit resurrection is required",
776                        edge.id,
777                    ),
778                };
779                Err(RuntimeError::InvalidInput(format!(
780                    "edge natural key is soft-deleted; pass resurrect=true to link explicitly: {error}"
781                )))
782            }
783            _ => Err(RuntimeError::Internal(
784                "link: unexpected composition outcome".into(),
785            )),
786        }
787    }
788
789    fn link_mutation_event(
790        attribution: &crate::EventAttribution,
791        result: &EdgeUpsertResult,
792        source_kind: EdgeEndpointKind,
793        target_kind: EdgeEndpointKind,
794    ) -> Event {
795        let kind = match result.disposition {
796            EdgeUpsertDisposition::Created => EventKind::LinkCreated,
797            EdgeUpsertDisposition::Updated | EdgeUpsertDisposition::Resurrected => {
798                EventKind::EdgeUpdated
799            }
800        };
801        let edge_id = Uuid::from(result.edge.id);
802        let mut payload = serde_json::json!({
803            "id": edge_id,
804            "namespace": result.edge.namespace,
805            "mutation": result.disposition.name(),
806            "source_id": result.edge.source_id,
807            "target_id": result.edge.target_id,
808            "relation": result.edge.relation,
809            "weight": result.edge.weight,
810            "metadata": result.edge.metadata,
811            "previous": result.previous,
812        });
813        if kind == EventKind::LinkCreated {
814            payload["source_kind"] = serde_json::json!(source_kind.name());
815            payload["target_kind"] = serde_json::json!(target_kind.name());
816        }
817        attribution.stamp(
818            Event::new(
819                result.edge.namespace.clone(),
820                "link",
821                kind,
822                SubstrateKind::Entity,
823                "",
824            )
825            .with_target(edge_id)
826            .with_payload(payload),
827        )
828    }
829
830    fn link_composition_shape_error(message: &'static str) -> khive_storage::StorageError {
831        khive_storage::StorageError::InvalidInput {
832            capability: khive_storage::StorageCapability::Graph,
833            operation: "link_mutation_event".into(),
834            message: message.into(),
835        }
836    }
837
838    /// Validate and construct an edge from a [`LinkSpec`] without writing to storage.
839    ///
840    /// Applies the full edge contract (endpoint validation, symmetric
841    /// canonicalization, `dependency_kind` inference and metadata validation).
842    /// Returns the constructed `Edge` on success; the caller is responsible for
843    /// persisting it (e.g. via `upsert_edge` or `link_many`).
844    ///
845    /// The `token` must be a pre-authorized namespace token from the dispatch
846    /// layer. If `spec.namespace` is set it must match `token.namespace()`;
847    /// a mismatch returns `RuntimeError::InvalidInput`.
848    pub async fn build_edge(&self, token: &NamespaceToken, spec: &LinkSpec) -> RuntimeResult<Edge> {
849        self.build_edge_with_endpoint_kinds(token, spec)
850            .await
851            .map(|(edge, _)| edge)
852    }
853
854    async fn build_edge_with_endpoint_kinds(
855        &self,
856        token: &NamespaceToken,
857        spec: &LinkSpec,
858    ) -> RuntimeResult<(Edge, (EdgeEndpointKind, EdgeEndpointKind))> {
859        validate_edge_metadata(spec.relation, spec.metadata.as_ref())?;
860        let ns_str = match &spec.namespace {
861            Some(s) => {
862                let spec_ns = crate::Namespace::parse(s)
863                    .map_err(|e| RuntimeError::InvalidInput(format!("invalid namespace: {e}")))?;
864                if &spec_ns != token.namespace() {
865                    return Err(RuntimeError::InvalidInput(
866                        "LinkSpec namespace does not match token namespace".into(),
867                    ));
868                }
869                s.as_str()
870            }
871            None => token.namespace().as_str(),
872        };
873        let endpoint_kinds = self
874            .validate_edge_relation_endpoints(token, spec.source_id, spec.target_id, spec.relation)
875            .await?;
876        let (source_id, target_id) =
877            canonical_edge_endpoints(spec.relation, spec.source_id, spec.target_id);
878        let endpoint_kinds = canonical_edge_endpoint_kinds(
879            spec.source_id,
880            source_id,
881            endpoint_kinds.0,
882            endpoint_kinds.1,
883        );
884        let metadata = if spec.relation == EdgeRelation::DependsOn {
885            // By-ID, unfiltered — matches the namespace-agnostic endpoint validation
886            // above. The visible-set-scoped `resolve` would silently drop the
887            // dependency_kind inference for endpoints validation now allows outside
888            // the caller's visible set.
889            match (
890                self.resolve_edge_endpoint(token, source_id).await?,
891                self.resolve_edge_endpoint(token, target_id).await?,
892            ) {
893                (Some(Resolved::Entity(src_e)), Some(Resolved::Entity(tgt_e))) => {
894                    merge_dependency_kind(&src_e.kind, &tgt_e.kind, spec.metadata.clone())
895                }
896                _ => spec.metadata.clone(),
897            }
898        } else {
899            spec.metadata.clone()
900        };
901        validate_edge_metadata(spec.relation, metadata.as_ref())?;
902        let now = chrono::Utc::now();
903        Ok((
904            Edge {
905                id: LinkId::from(Uuid::new_v4()),
906                namespace: ns_str.to_string(),
907                source_id,
908                target_id,
909                relation: spec.relation,
910                weight: spec.weight,
911                created_at: now,
912                updated_at: now,
913                deleted_at: None,
914                metadata,
915                target_backend: None,
916            },
917            endpoint_kinds,
918        ))
919    }
920
921    /// Validate and atomically upsert a batch of edges.
922    ///
923    /// All edges are validated and constructed with `build_edge` before any
924    /// write. If validation fails for any entry the entire batch is rejected
925    /// (no writes occur). On success, all edges are persisted in a single
926    /// source transaction with every lifecycle event and observation projection.
927    ///
928    /// After the bulk upsert, each edge is read back by its natural key
929    /// (namespace, source_id, target_id, relation) so that the returned IDs
930    /// are always the persisted row IDs, not the locally-generated UUIDs that
931    /// may have been displaced by an ON CONFLICT DO UPDATE. This mirrors the
932    /// same read-back applied to singleton `link()` and prevents phantom-ID
933    /// exposure when callers upsert overlapping triples with `verbose=true`.
934    ///
935    /// All specs must share the same namespace; the namespace is taken from
936    /// `token` (or validated against it if `spec.namespace` is set).
937    pub async fn link_many(
938        &self,
939        token: &NamespaceToken,
940        specs: Vec<LinkSpec>,
941    ) -> RuntimeResult<Vec<Edge>> {
942        self.link_many_observed(token, specs)
943            .await
944            .map(|rows| rows.into_iter().map(|row| row.edge).collect())
945    }
946
947    /// Observed all-or-nothing bulk link upsert. Every row carries its own
948    /// create/update/resurrection disposition, and every tombstone policy is
949    /// preflighted inside the same writer transaction before any mutation.
950    pub async fn link_many_observed(
951        &self,
952        token: &NamespaceToken,
953        specs: Vec<LinkSpec>,
954    ) -> RuntimeResult<Vec<EdgeUpsertResult>> {
955        self.link_many_guarded_observed(
956            token,
957            specs,
958            GraphMutationPreconditions::default(),
959            Vec::new(),
960        )
961        .await
962        .map(|(rows, _)| rows)
963    }
964
965    /// Internal seam for khive-runtime; no compatibility promise.
966    ///
967    /// Guarded composition seam for packs.
968    #[doc(hidden)]
969    pub async fn link_many_guarded_observed(
970        &self,
971        token: &NamespaceToken,
972        specs: Vec<LinkSpec>,
973        preconditions: GraphMutationPreconditions,
974        retirements: Vec<Edge>,
975    ) -> RuntimeResult<(Vec<EdgeUpsertResult>, Vec<LinkId>)> {
976        let namespace = token.namespace().as_str();
977        let foreign_document = preconditions
978            .document
979            .as_ref()
980            .is_some_and(|guard| guard.namespace != namespace);
981        let foreign_edge = preconditions.edges.iter().any(|guard| {
982            guard.namespace != namespace
983                || guard
984                    .expected
985                    .as_ref()
986                    .is_some_and(|edge| edge.namespace != namespace)
987        });
988        if foreign_document
989            || foreign_edge
990            || retirements.iter().any(|edge| edge.namespace != namespace)
991        {
992            return Err(RuntimeError::InvalidInput(
993                "guarded link namespace does not match token namespace".into(),
994            ));
995        }
996        if specs.is_empty()
997            && preconditions.document.is_none()
998            && preconditions.edges.is_empty()
999            && retirements.is_empty()
1000        {
1001            return Ok((Vec::new(), Vec::new()));
1002        }
1003        let mut edges = Vec::with_capacity(specs.len());
1004        let mut endpoint_kinds = Vec::with_capacity(specs.len());
1005        for spec in &specs {
1006            let (edge, kinds) = self.build_edge_with_endpoint_kinds(token, spec).await?;
1007            edges.push(edge);
1008            endpoint_kinds.push(kinds);
1009        }
1010        // `upsert_edges_guarded` re-checks every edge's endpoints as part of the
1011        // same write, not the separate per-spec `build_edge` validation reads
1012        // above. A concurrent hard-delete of any endpoint landing between those
1013        // reads and this write aborts the whole batch (all-or-nothing, no
1014        // partial write) instead of persisting a dangling edge. The failing
1015        // entry's index and its missing endpoint(s) come from the guard's own
1016        // in-transaction pre-check (`GuardedBatchOutcome::refused`), not a
1017        // post-hoc re-read of the batch after the write already failed.
1018        let requests = edges
1019            .into_iter()
1020            .zip(specs.iter())
1021            .map(|(edge, spec)| EdgeUpsertRequest {
1022                edge,
1023                resurrect: spec.resurrect,
1024            })
1025            .collect();
1026        let attribution = crate::EventAttribution::from_token(token);
1027        let outcome = compose_graph_mutation_events(
1028            self.backend(),
1029            GraphMutationRequest::Batch {
1030                requests,
1031                guard_endpoints: true,
1032            },
1033            preconditions,
1034            retirements,
1035            move |outcome| {
1036                let GraphMutationOutcome::Batch(batch) = &outcome.mutation else {
1037                    return Err(Self::link_composition_shape_error(
1038                        "expected a written batch",
1039                    ));
1040                };
1041                if batch.rows.len() != endpoint_kinds.len() {
1042                    return Err(Self::link_composition_shape_error(
1043                        "edge result count differs from validated endpoint count",
1044                    ));
1045                }
1046                let mut events = Vec::with_capacity(batch.rows.len() + outcome.retired.len());
1047                for (row, (source_kind, target_kind)) in batch.rows.iter().zip(endpoint_kinds) {
1048                    events.push(Self::link_mutation_event(
1049                        &attribution,
1050                        row,
1051                        source_kind,
1052                        target_kind,
1053                    ));
1054                }
1055                for edge in &outcome.retired {
1056                    let edge_id = Uuid::from(edge.id);
1057                    events.push(
1058                        attribution.stamp(
1059                            Event::new(
1060                                edge.namespace.clone(),
1061                                "delete",
1062                                EventKind::EdgeDeleted,
1063                                SubstrateKind::Entity,
1064                                "",
1065                            )
1066                            .with_target(edge_id)
1067                            .with_payload(serde_json::json!({
1068                                "id": edge_id, "namespace": edge.namespace, "hard": false,
1069                            })),
1070                        ),
1071                    );
1072                }
1073                Ok(events)
1074            },
1075        )
1076        .await?;
1077        let retired = outcome.retired.into_iter().map(|edge| edge.id).collect();
1078        let GraphMutationOutcome::Batch(outcome) = outcome.mutation else {
1079            return Err(RuntimeError::Internal(
1080                "link_many: unexpected composition outcome".into(),
1081            ));
1082        };
1083        if let Some(refusal) = outcome.refusal {
1084            return match refusal.reason {
1085                EdgeUpsertRefusal::MissingEndpoints(missing) => {
1086                    Err(RuntimeError::GuardedWriteFailed(guarded_link_batch_failure(
1087                        &specs[refusal.entry_index],
1088                        refusal.entry_index,
1089                        missing,
1090                    )))
1091                }
1092                EdgeUpsertRefusal::ResurrectionRequired { edge } => {
1093                    Err(RuntimeError::InvalidInput(format!(
1094                        "batch entry {} targets soft-deleted edge {}; pass resurrect=true for that link",
1095                        refusal.entry_index, edge.id
1096                    )))
1097                }
1098            };
1099        }
1100        Ok((outcome.rows, retired))
1101    }
1102
1103    /// Create a historical commit-to-project annotation without replacing a
1104    /// curated edge or reviving a tombstone. The store rechecks the exact live
1105    /// commit SHA and project under its writer transaction; only a newly
1106    /// inserted edge produces the ordinary LinkCreated lifecycle event.
1107    pub async fn link_commit_annotation_if_absent(
1108        &self,
1109        token: &NamespaceToken,
1110        commit_id: Uuid,
1111        project_id: Uuid,
1112        guard: CommitAnnotationGuard,
1113    ) -> RuntimeResult<CommitAnnotationInsertOutcome> {
1114        if !matches!(guard.expected_sha.len(), 40 | 64)
1115            || !guard
1116                .expected_sha
1117                .bytes()
1118                .all(|byte| byte.is_ascii_hexdigit())
1119        {
1120            return Err(RuntimeError::InvalidInput(
1121                "expected full commit SHA".into(),
1122            ));
1123        }
1124        let edge = self
1125            .build_edge(
1126                token,
1127                &LinkSpec {
1128                    namespace: None,
1129                    source_id: commit_id,
1130                    target_id: project_id,
1131                    relation: EdgeRelation::Annotates,
1132                    weight: 1.0,
1133                    metadata: None,
1134                    resurrect: false,
1135                },
1136            )
1137            .await?;
1138        let attribution = crate::EventAttribution::from_token(token);
1139        let outcome = compose_graph_mutation_events(
1140            self.backend(),
1141            GraphMutationRequest::CommitAnnotation { edge, guard },
1142            GraphMutationPreconditions::default(),
1143            Vec::new(),
1144            move |outcome| match &outcome.mutation {
1145                GraphMutationOutcome::CommitAnnotation(CommitAnnotationInsertOutcome::Created(
1146                    edge,
1147                )) => Ok(vec![Self::link_mutation_event(
1148                    &attribution,
1149                    &EdgeUpsertResult {
1150                        edge: edge.clone(),
1151                        disposition: EdgeUpsertDisposition::Created,
1152                        previous: None,
1153                    },
1154                    EdgeEndpointKind::Note,
1155                    EdgeEndpointKind::Entity,
1156                )]),
1157                _ => Err(Self::link_composition_shape_error(
1158                    "expected a created annotation",
1159                )),
1160            },
1161        )
1162        .await?;
1163        let GraphMutationOutcome::CommitAnnotation(result) = outcome.mutation else {
1164            return Err(RuntimeError::Internal(
1165                "link annotation: unexpected composition outcome".into(),
1166            ));
1167        };
1168        Ok(result)
1169    }
1170}