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