Skip to main content

khive_runtime/operations/
link_ops.rs

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