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 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 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 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 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 #[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 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 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 #[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 #[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 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 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 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 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 #[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 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 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}