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 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 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 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 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 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 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 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 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 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 pub fn validate_link_endpoints_by_resolved(
303 &self,
304 source_id: Uuid,
305 target_id: Uuid,
306 relation: EdgeRelation,
307 src: Option<&Resolved>,
308 tgt: Option<&Resolved>,
309 ) -> RuntimeResult<()> {
310 if source_id == target_id {
311 return Err(RuntimeError::InvalidInput(
312 "self-loop edges are not allowed: source_id and target_id must be different".into(),
313 ));
314 }
315
316 if relation == EdgeRelation::Annotates {
317 match src {
318 Some(Resolved::Note(_)) => {}
319 Some(_) => {
320 return Err(RuntimeError::InvalidInput(format!(
321 "annotates source {source_id} must be a note"
322 )));
323 }
324 None => {
325 return Err(RuntimeError::NotFound(format!(
326 "link source {source_id} not found"
327 )));
328 }
329 }
330 if tgt.is_none() {
331 return Err(RuntimeError::NotFound(format!(
332 "link target {target_id} not found"
333 )));
334 }
335 return Ok(());
336 }
337
338 if crate::pack::is_special_relation(relation) {
339 let rel_name = relation.as_str();
340 let src = src.ok_or_else(|| {
341 RuntimeError::NotFound(format!("link source {source_id} not found"))
342 })?;
343 let tgt = tgt.ok_or_else(|| {
344 RuntimeError::NotFound(format!("link target {target_id} not found"))
345 })?;
346 match (src, tgt) {
347 (Resolved::Entity(src_e), Resolved::Entity(tgt_e)) => {
348 if !base_entity_rule_allows(&src_e.kind, relation, &tgt_e.kind) {
349 let legal_relations = accepted_entity_relations_description(
350 &self.pack_edge_rules(),
351 &src_e.kind,
352 src_e.entity_type.as_deref(),
353 &tgt_e.kind,
354 tgt_e.entity_type.as_deref(),
355 );
356 let rule_hint = match relation {
357 EdgeRelation::Supports | EdgeRelation::Refutes => {
358 "requires concept|document|dataset|artifact -> concept \
359 (or same-substrate note -> note)"
360 }
361 _ => "requires same-kind entity endpoints",
362 };
363 return Err(RuntimeError::InvalidInput(format!(
364 "({}) -[{rel_name}]-> ({}) is not in the base endpoint \
365 allowlist; {rel_name} {rule_hint}; currently legal relations for \
366 {} -> {} under the loaded endpoint rules: {legal_relations}",
367 src_e.kind, tgt_e.kind, src_e.kind, tgt_e.kind
368 )));
369 }
370 }
371 (Resolved::Note(_), Resolved::Note(_)) => {}
372 (Resolved::Entity(_), Resolved::Note(_)) => {
373 return Err(RuntimeError::InvalidInput(format!(
374 "{rel_name} endpoints must be the same substrate \
375 (note→note or entity→entity); got source={source_id} (entity) \
376 target={target_id} (note)"
377 )));
378 }
379 (Resolved::Note(_), Resolved::Entity(_)) => {
380 return Err(RuntimeError::InvalidInput(format!(
381 "{rel_name} endpoints must be the same substrate \
382 (note→note or entity→entity); got source={source_id} (note) \
383 target={target_id} (entity)"
384 )));
385 }
386 (Resolved::PackRecord { .. }, _) | (_, Resolved::PackRecord { .. }) => {
387 return Err(RuntimeError::InvalidInput(format!(
388 "pack-private record is not a valid edge endpoint for {rel_name}"
389 )));
390 }
391 _ => {
392 return Err(RuntimeError::InvalidInput(format!(
393 "{rel_name} endpoints must be notes or entities (not events)"
394 )));
395 }
396 }
397 return Ok(());
398 }
399
400 let pack_rules = self.pack_edge_rules();
403 if pack_rule_allows(&pack_rules, relation, src, tgt) {
404 return Ok(());
405 }
406
407 let (src_kind, src_entity_type) = match src {
408 Some(Resolved::Entity(e)) => (e.kind.as_str(), e.entity_type.as_deref()),
409 Some(_) => {
410 return Err(RuntimeError::InvalidInput(format!(
411 "link source {source_id} must be an entity for relation {relation:?} \
412 (only `annotates` crosses substrates)"
413 )));
414 }
415 None => {
416 return Err(RuntimeError::NotFound(format!(
417 "link source {source_id} not found"
418 )));
419 }
420 };
421 let (tgt_kind, tgt_entity_type) = match tgt {
422 Some(Resolved::Entity(e)) => (e.kind.as_str(), e.entity_type.as_deref()),
423 Some(_) => {
424 return Err(RuntimeError::InvalidInput(format!(
425 "link target {target_id} must be an entity for relation {relation:?} \
426 (only `annotates` crosses substrates)"
427 )));
428 }
429 None => {
430 return Err(RuntimeError::NotFound(format!(
431 "link target {target_id} not found"
432 )));
433 }
434 };
435
436 if !base_entity_rule_allows(src_kind, relation, tgt_kind) {
437 let legal_relations = accepted_entity_relations_description(
438 &pack_rules,
439 src_kind,
440 src_entity_type,
441 tgt_kind,
442 tgt_entity_type,
443 );
444 return Err(RuntimeError::InvalidInput(format!(
445 "({src_kind}) -[{}]-> ({tgt_kind}) is not in the base endpoint \
446 allowlist; use pack EDGE_RULES to extend the allowlist; currently legal relations \
447 for {src_kind} -> {tgt_kind} under the loaded endpoint rules: {legal_relations}",
448 relation.as_str()
449 )));
450 }
451
452 Ok(())
453 }
454
455 pub fn validate_annotates_endpoint_kinds(
467 &self,
468 source_id: Uuid,
469 target_id: Uuid,
470 source: Option<EdgeEndpointKind>,
471 target: Option<EdgeEndpointKind>,
472 ) -> RuntimeResult<()> {
473 if source_id == target_id {
474 return Err(RuntimeError::InvalidInput(
475 "self-loop edges are not allowed: source_id and target_id must be different".into(),
476 ));
477 }
478 match source {
479 Some(EdgeEndpointKind::Note) => {}
480 Some(_) => {
481 return Err(RuntimeError::InvalidInput(format!(
482 "annotates source {source_id} must be a note"
483 )));
484 }
485 None => {
486 return Err(RuntimeError::NotFound(format!(
487 "link source {source_id} not found"
488 )));
489 }
490 }
491 if target.is_none() {
492 return Err(RuntimeError::NotFound(format!(
493 "link target {target_id} not found"
494 )));
495 }
496 Ok(())
497 }
498
499 pub async fn link(
519 &self,
520 token: &NamespaceToken,
521 source_id: Uuid,
522 target_id: Uuid,
523 relation: EdgeRelation,
524 weight: f64,
525 metadata: Option<serde_json::Value>,
526 ) -> RuntimeResult<Edge> {
527 self.link_observed(
528 token, source_id, target_id, relation, weight, metadata, false,
529 )
530 .await
531 .map(|result| result.edge)
532 }
533
534 #[allow(clippy::too_many_arguments)]
539 pub async fn link_observed(
540 &self,
541 token: &NamespaceToken,
542 source_id: Uuid,
543 target_id: Uuid,
544 relation: EdgeRelation,
545 weight: f64,
546 metadata: Option<serde_json::Value>,
547 resurrect: bool,
548 ) -> RuntimeResult<EdgeUpsertResult> {
549 validate_edge_weight(weight)?;
550 validate_edge_metadata(relation, metadata.as_ref())?;
551 let (source_kind, target_kind) = self
552 .validate_edge_relation_endpoints(token, source_id, target_id, relation)
553 .await?;
554 let (canonical_source, canonical_target) =
555 canonical_edge_endpoints(relation, source_id, target_id);
556 let (source_kind, target_kind) =
557 canonical_edge_endpoint_kinds(source_id, canonical_source, source_kind, target_kind);
558 let (source_id, target_id) = (canonical_source, canonical_target);
559 let metadata = if relation == EdgeRelation::DependsOn {
560 match (
565 self.resolve_edge_endpoint(token, source_id).await?,
566 self.resolve_edge_endpoint(token, target_id).await?,
567 ) {
568 (Some(Resolved::Entity(src_e)), Some(Resolved::Entity(tgt_e))) => {
569 merge_dependency_kind(&src_e.kind, &tgt_e.kind, metadata)
570 }
571 _ => metadata,
572 }
573 } else {
574 metadata
575 };
576 validate_edge_metadata(relation, metadata.as_ref())?;
577 let now = chrono::Utc::now();
578 let ns = token.namespace().as_str();
579 let edge = Edge {
580 id: LinkId::from(Uuid::new_v4()),
581 namespace: ns.to_string(),
582 source_id,
583 target_id,
584 relation,
585 weight,
586 created_at: now,
587 updated_at: now,
588 deleted_at: None,
589 metadata,
590 target_backend: None,
591 };
592 let attribution = crate::EventAttribution::from_token(token);
602 let outcome = compose_graph_mutation_events(
603 self.backend(),
604 GraphMutationRequest::Single {
605 request: EdgeUpsertRequest { edge, resurrect },
606 guard_endpoints: true,
607 },
608 GraphMutationPreconditions::default(),
609 Vec::new(),
610 move |outcome| match &outcome.mutation {
611 GraphMutationOutcome::Single(GuardedEdgeUpsertOutcome::Written(result)) => {
612 Ok(vec![Self::link_mutation_event(
613 &attribution,
614 result,
615 source_kind,
616 target_kind,
617 )])
618 }
619 _ => Err(Self::link_composition_shape_error(
620 "expected a written singleton",
621 )),
622 },
623 )
624 .await?;
625 let GraphMutationOutcome::Single(outcome) = outcome.mutation else {
626 return Err(RuntimeError::Internal(
627 "link: unexpected composition outcome".into(),
628 ));
629 };
630 let result = match outcome {
631 GuardedEdgeUpsertOutcome::Written(result) => result,
632 GuardedEdgeUpsertOutcome::Refused(EdgeUpsertRefusal::MissingEndpoints(missing)) => {
633 return Err(RuntimeError::GuardedWriteFailed(GuardedWriteFailure {
634 entry_index: None,
635 missing_source: missing.source.then_some(source_id),
636 missing_target: missing.target.then_some(target_id),
637 }));
638 }
639 GuardedEdgeUpsertOutcome::Refused(EdgeUpsertRefusal::ResurrectionRequired { edge }) => {
640 return Err(RuntimeError::InvalidInput(format!(
641 "edge {} is soft-deleted; pass resurrect=true to link explicitly",
642 edge.id
643 )))
644 }
645 };
646 Ok(result)
647 }
648
649 #[allow(clippy::too_many_arguments)]
657 pub async fn link_with_target_backend(
658 &self,
659 token: &NamespaceToken,
660 source_id: Uuid,
661 target_id: Uuid,
662 source_kind: EdgeEndpointKind,
663 target_kind: EdgeEndpointKind,
664 relation: EdgeRelation,
665 weight: f64,
666 metadata: Option<serde_json::Value>,
667 target_backend: Option<String>,
668 ) -> RuntimeResult<Edge> {
669 self.link_with_target_backend_observed(
670 token,
671 source_id,
672 target_id,
673 source_kind,
674 target_kind,
675 relation,
676 weight,
677 metadata,
678 target_backend,
679 false,
680 )
681 .await
682 .map(|result| result.edge)
683 }
684
685 #[allow(clippy::too_many_arguments)]
689 pub async fn link_with_target_backend_observed(
690 &self,
691 token: &NamespaceToken,
692 source_id: Uuid,
693 target_id: Uuid,
694 source_kind: EdgeEndpointKind,
695 target_kind: EdgeEndpointKind,
696 relation: EdgeRelation,
697 weight: f64,
698 metadata: Option<serde_json::Value>,
699 target_backend: Option<String>,
700 resurrect: bool,
701 ) -> RuntimeResult<EdgeUpsertResult> {
702 validate_edge_weight(weight)?;
703 let (canonical_source, canonical_target) =
704 canonical_edge_endpoints(relation, source_id, target_id);
705 let (source_kind, target_kind) =
706 canonical_edge_endpoint_kinds(source_id, canonical_source, source_kind, target_kind);
707 let (source_id, target_id) = (canonical_source, canonical_target);
708 validate_edge_metadata(relation, metadata.as_ref())?;
709 let now = chrono::Utc::now();
710 let ns = token.namespace().as_str();
711 let edge = Edge {
712 id: LinkId::from(Uuid::new_v4()),
713 namespace: ns.to_string(),
714 source_id,
715 target_id,
716 relation,
717 weight,
718 created_at: now,
719 updated_at: now,
720 deleted_at: None,
721 metadata,
722 target_backend,
723 };
724 let attribution = crate::EventAttribution::from_token(token);
725 let outcome = compose_graph_mutation_events(
726 self.backend(),
727 GraphMutationRequest::Single {
728 request: EdgeUpsertRequest { edge, resurrect },
729 guard_endpoints: false,
730 },
731 GraphMutationPreconditions::default(),
732 Vec::new(),
733 move |outcome| match &outcome.mutation {
734 GraphMutationOutcome::Single(GuardedEdgeUpsertOutcome::Written(result)) => {
735 Ok(vec![Self::link_mutation_event(
736 &attribution,
737 result,
738 source_kind,
739 target_kind,
740 )])
741 }
742 _ => Err(Self::link_composition_shape_error(
743 "expected a written singleton",
744 )),
745 },
746 )
747 .await?;
748 match outcome.mutation {
749 GraphMutationOutcome::Single(GuardedEdgeUpsertOutcome::Written(result)) => Ok(result),
750 GraphMutationOutcome::Single(GuardedEdgeUpsertOutcome::Refused(
751 EdgeUpsertRefusal::ResurrectionRequired { edge },
752 )) => {
753 let error = khive_storage::StorageError::Conflict {
754 capability: khive_storage::StorageCapability::Graph,
755 operation: "upsert_edge_observed".into(),
756 message: format!(
757 "edge {} is soft-deleted; explicit resurrection is required",
758 edge.id,
759 ),
760 };
761 Err(RuntimeError::InvalidInput(format!(
762 "edge natural key is soft-deleted; pass resurrect=true to link explicitly: {error}"
763 )))
764 }
765 _ => Err(RuntimeError::Internal(
766 "link: unexpected composition outcome".into(),
767 )),
768 }
769 }
770
771 fn link_mutation_event(
772 attribution: &crate::EventAttribution,
773 result: &EdgeUpsertResult,
774 source_kind: EdgeEndpointKind,
775 target_kind: EdgeEndpointKind,
776 ) -> Event {
777 let kind = match result.disposition {
778 EdgeUpsertDisposition::Created => EventKind::LinkCreated,
779 EdgeUpsertDisposition::Updated | EdgeUpsertDisposition::Resurrected => {
780 EventKind::EdgeUpdated
781 }
782 };
783 let edge_id = Uuid::from(result.edge.id);
784 let mut payload = serde_json::json!({
785 "id": edge_id,
786 "namespace": result.edge.namespace,
787 "mutation": result.disposition.name(),
788 "source_id": result.edge.source_id,
789 "target_id": result.edge.target_id,
790 "relation": result.edge.relation,
791 "weight": result.edge.weight,
792 "metadata": result.edge.metadata,
793 "previous": result.previous,
794 });
795 if kind == EventKind::LinkCreated {
796 payload["source_kind"] = serde_json::json!(source_kind.name());
797 payload["target_kind"] = serde_json::json!(target_kind.name());
798 }
799 attribution.stamp(
800 Event::new(
801 result.edge.namespace.clone(),
802 "link",
803 kind,
804 SubstrateKind::Entity,
805 "",
806 )
807 .with_target(edge_id)
808 .with_payload(payload),
809 )
810 }
811
812 fn link_composition_shape_error(message: &'static str) -> khive_storage::StorageError {
813 khive_storage::StorageError::InvalidInput {
814 capability: khive_storage::StorageCapability::Graph,
815 operation: "link_mutation_event".into(),
816 message: message.into(),
817 }
818 }
819
820 pub async fn build_edge(&self, token: &NamespaceToken, spec: &LinkSpec) -> RuntimeResult<Edge> {
831 self.build_edge_with_endpoint_kinds(token, spec)
832 .await
833 .map(|(edge, _)| edge)
834 }
835
836 async fn build_edge_with_endpoint_kinds(
837 &self,
838 token: &NamespaceToken,
839 spec: &LinkSpec,
840 ) -> RuntimeResult<(Edge, (EdgeEndpointKind, EdgeEndpointKind))> {
841 validate_edge_metadata(spec.relation, spec.metadata.as_ref())?;
842 let ns_str = match &spec.namespace {
843 Some(s) => {
844 let spec_ns = crate::Namespace::parse(s)
845 .map_err(|e| RuntimeError::InvalidInput(format!("invalid namespace: {e}")))?;
846 if &spec_ns != token.namespace() {
847 return Err(RuntimeError::InvalidInput(
848 "LinkSpec namespace does not match token namespace".into(),
849 ));
850 }
851 s.as_str()
852 }
853 None => token.namespace().as_str(),
854 };
855 let endpoint_kinds = self
856 .validate_edge_relation_endpoints(token, spec.source_id, spec.target_id, spec.relation)
857 .await?;
858 let (source_id, target_id) =
859 canonical_edge_endpoints(spec.relation, spec.source_id, spec.target_id);
860 let endpoint_kinds = canonical_edge_endpoint_kinds(
861 spec.source_id,
862 source_id,
863 endpoint_kinds.0,
864 endpoint_kinds.1,
865 );
866 let metadata = if spec.relation == EdgeRelation::DependsOn {
867 match (
872 self.resolve_edge_endpoint(token, source_id).await?,
873 self.resolve_edge_endpoint(token, target_id).await?,
874 ) {
875 (Some(Resolved::Entity(src_e)), Some(Resolved::Entity(tgt_e))) => {
876 merge_dependency_kind(&src_e.kind, &tgt_e.kind, spec.metadata.clone())
877 }
878 _ => spec.metadata.clone(),
879 }
880 } else {
881 spec.metadata.clone()
882 };
883 validate_edge_metadata(spec.relation, metadata.as_ref())?;
884 let now = chrono::Utc::now();
885 Ok((
886 Edge {
887 id: LinkId::from(Uuid::new_v4()),
888 namespace: ns_str.to_string(),
889 source_id,
890 target_id,
891 relation: spec.relation,
892 weight: spec.weight,
893 created_at: now,
894 updated_at: now,
895 deleted_at: None,
896 metadata,
897 target_backend: None,
898 },
899 endpoint_kinds,
900 ))
901 }
902
903 pub async fn link_many(
920 &self,
921 token: &NamespaceToken,
922 specs: Vec<LinkSpec>,
923 ) -> RuntimeResult<Vec<Edge>> {
924 self.link_many_observed(token, specs)
925 .await
926 .map(|rows| rows.into_iter().map(|row| row.edge).collect())
927 }
928
929 pub async fn link_many_observed(
933 &self,
934 token: &NamespaceToken,
935 specs: Vec<LinkSpec>,
936 ) -> RuntimeResult<Vec<EdgeUpsertResult>> {
937 self.link_many_guarded_observed(
938 token,
939 specs,
940 GraphMutationPreconditions::default(),
941 Vec::new(),
942 )
943 .await
944 .map(|(rows, _)| rows)
945 }
946
947 #[doc(hidden)]
951 pub async fn link_many_guarded_observed(
952 &self,
953 token: &NamespaceToken,
954 specs: Vec<LinkSpec>,
955 preconditions: GraphMutationPreconditions,
956 retirements: Vec<Edge>,
957 ) -> RuntimeResult<(Vec<EdgeUpsertResult>, Vec<LinkId>)> {
958 let namespace = token.namespace().as_str();
959 let foreign_document = preconditions
960 .document
961 .as_ref()
962 .is_some_and(|guard| guard.namespace != namespace);
963 let foreign_edge = preconditions.edges.iter().any(|guard| {
964 guard.namespace != namespace
965 || guard
966 .expected
967 .as_ref()
968 .is_some_and(|edge| edge.namespace != namespace)
969 });
970 if foreign_document
971 || foreign_edge
972 || retirements.iter().any(|edge| edge.namespace != namespace)
973 {
974 return Err(RuntimeError::InvalidInput(
975 "guarded link namespace does not match token namespace".into(),
976 ));
977 }
978 if specs.is_empty()
979 && preconditions.document.is_none()
980 && preconditions.edges.is_empty()
981 && retirements.is_empty()
982 {
983 return Ok((Vec::new(), Vec::new()));
984 }
985 let mut edges = Vec::with_capacity(specs.len());
986 let mut endpoint_kinds = Vec::with_capacity(specs.len());
987 for spec in &specs {
988 let (edge, kinds) = self.build_edge_with_endpoint_kinds(token, spec).await?;
989 edges.push(edge);
990 endpoint_kinds.push(kinds);
991 }
992 let requests = edges
1001 .into_iter()
1002 .zip(specs.iter())
1003 .map(|(edge, spec)| EdgeUpsertRequest {
1004 edge,
1005 resurrect: spec.resurrect,
1006 })
1007 .collect();
1008 let attribution = crate::EventAttribution::from_token(token);
1009 let outcome = compose_graph_mutation_events(
1010 self.backend(),
1011 GraphMutationRequest::Batch {
1012 requests,
1013 guard_endpoints: true,
1014 },
1015 preconditions,
1016 retirements,
1017 move |outcome| {
1018 let GraphMutationOutcome::Batch(batch) = &outcome.mutation else {
1019 return Err(Self::link_composition_shape_error(
1020 "expected a written batch",
1021 ));
1022 };
1023 if batch.rows.len() != endpoint_kinds.len() {
1024 return Err(Self::link_composition_shape_error(
1025 "edge result count differs from validated endpoint count",
1026 ));
1027 }
1028 let mut events = Vec::with_capacity(batch.rows.len() + outcome.retired.len());
1029 for (row, (source_kind, target_kind)) in batch.rows.iter().zip(endpoint_kinds) {
1030 events.push(Self::link_mutation_event(
1031 &attribution,
1032 row,
1033 source_kind,
1034 target_kind,
1035 ));
1036 }
1037 for edge in &outcome.retired {
1038 let edge_id = Uuid::from(edge.id);
1039 events.push(
1040 attribution.stamp(
1041 Event::new(
1042 edge.namespace.clone(),
1043 "delete",
1044 EventKind::EdgeDeleted,
1045 SubstrateKind::Entity,
1046 "",
1047 )
1048 .with_target(edge_id)
1049 .with_payload(serde_json::json!({
1050 "id": edge_id, "namespace": edge.namespace, "hard": false,
1051 })),
1052 ),
1053 );
1054 }
1055 Ok(events)
1056 },
1057 )
1058 .await?;
1059 let retired = outcome.retired.into_iter().map(|edge| edge.id).collect();
1060 let GraphMutationOutcome::Batch(outcome) = outcome.mutation else {
1061 return Err(RuntimeError::Internal(
1062 "link_many: unexpected composition outcome".into(),
1063 ));
1064 };
1065 if let Some(refusal) = outcome.refusal {
1066 return match refusal.reason {
1067 EdgeUpsertRefusal::MissingEndpoints(missing) => {
1068 Err(RuntimeError::GuardedWriteFailed(guarded_link_batch_failure(
1069 &specs[refusal.entry_index],
1070 refusal.entry_index,
1071 missing,
1072 )))
1073 }
1074 EdgeUpsertRefusal::ResurrectionRequired { edge } => {
1075 Err(RuntimeError::InvalidInput(format!(
1076 "batch entry {} targets soft-deleted edge {}; pass resurrect=true for that link",
1077 refusal.entry_index, edge.id
1078 )))
1079 }
1080 };
1081 }
1082 Ok((outcome.rows, retired))
1083 }
1084
1085 pub async fn link_commit_annotation_if_absent(
1090 &self,
1091 token: &NamespaceToken,
1092 commit_id: Uuid,
1093 project_id: Uuid,
1094 guard: CommitAnnotationGuard,
1095 ) -> RuntimeResult<CommitAnnotationInsertOutcome> {
1096 if !matches!(guard.expected_sha.len(), 40 | 64)
1097 || !guard
1098 .expected_sha
1099 .bytes()
1100 .all(|byte| byte.is_ascii_hexdigit())
1101 {
1102 return Err(RuntimeError::InvalidInput(
1103 "expected full commit SHA".into(),
1104 ));
1105 }
1106 let edge = self
1107 .build_edge(
1108 token,
1109 &LinkSpec {
1110 namespace: None,
1111 source_id: commit_id,
1112 target_id: project_id,
1113 relation: EdgeRelation::Annotates,
1114 weight: 1.0,
1115 metadata: None,
1116 resurrect: false,
1117 },
1118 )
1119 .await?;
1120 let attribution = crate::EventAttribution::from_token(token);
1121 let outcome = compose_graph_mutation_events(
1122 self.backend(),
1123 GraphMutationRequest::CommitAnnotation { edge, guard },
1124 GraphMutationPreconditions::default(),
1125 Vec::new(),
1126 move |outcome| match &outcome.mutation {
1127 GraphMutationOutcome::CommitAnnotation(CommitAnnotationInsertOutcome::Created(
1128 edge,
1129 )) => Ok(vec![Self::link_mutation_event(
1130 &attribution,
1131 &EdgeUpsertResult {
1132 edge: edge.clone(),
1133 disposition: EdgeUpsertDisposition::Created,
1134 previous: None,
1135 },
1136 EdgeEndpointKind::Note,
1137 EdgeEndpointKind::Entity,
1138 )]),
1139 _ => Err(Self::link_composition_shape_error(
1140 "expected a created annotation",
1141 )),
1142 },
1143 )
1144 .await?;
1145 let GraphMutationOutcome::CommitAnnotation(result) = outcome.mutation else {
1146 return Err(RuntimeError::Internal(
1147 "link annotation: unexpected composition outcome".into(),
1148 ));
1149 };
1150 Ok(result)
1151 }
1152}