1use crate::events::{EventBroker, EventError};
2use crate::security::{RuntimeWarning, verify_artifact};
3use crate::{
4 ExecutionFailureReason, ExecutionFailureState, LocalExecutor, Runtime, RuntimeError,
5 RuntimeErrorCode, RuntimeExecutionOutcome, execution_failure_outcome, runtime_error,
6 successful_execution_outcome, validate_payload_against_contract,
7};
8use serde::{Deserialize, Serialize};
9use serde_json::{Map, Value, json};
10use std::collections::BTreeSet;
11use traverse_contracts::{EventReference, ServiceType};
12use traverse_registry::{
13 LookupScope, RegistryScope, ResolvedCapability, ResolvedWorkflow, WorkflowEdge,
14 WorkflowEdgePredicate, WorkflowEdgeTrigger, WorkflowNode,
15};
16
17const WORKFLOW_REQUEST_KIND: &str = "workflow_execution_request";
18const WORKFLOW_EVIDENCE_KIND: &str = "workflow_traversal_evidence";
19const WORKFLOW_SCHEMA_VERSION: &str = "1.0.0";
20const WORKFLOW_GOVERNING_SPEC: &str = "007-workflow-registry-traversal";
21const EVENT_BROKER_POLL_BATCH: usize = 256;
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct WorkflowExecutionRequest {
27 pub kind: String,
28 pub schema_version: String,
29 pub request_id: String,
30 pub workflow_id: String,
31 pub workflow_version: String,
32 pub scope: WorkflowLookupScope,
33 pub input: Value,
34 pub governing_spec: String,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum WorkflowLookupScope {
40 PublicOnly,
41 PreferPrivate,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct WorkflowTraversalEvidence {
46 pub kind: String,
47 pub schema_version: String,
48 pub trace_id: String,
49 pub request_id: String,
50 pub workflow_id: String,
51 pub workflow_version: String,
52 pub governing_spec: String,
53 pub visited_nodes: Vec<WorkflowTraversalStepRecord>,
54 pub traversed_edges: Vec<WorkflowTraversalEdgeRecord>,
55 pub emitted_events: Vec<EventReference>,
56 #[serde(default)]
57 pub waiting_edges: Vec<WaitingWorkflowEdgeContext>,
58 #[serde(default)]
59 pub event_match_records: Vec<EventMatchRecord>,
60 #[serde(default)]
61 pub event_wake_decisions: Vec<EventWakeDecision>,
62 #[serde(default)]
63 pub event_consumptions: Vec<EventConsumptionRecord>,
64 pub result: WorkflowTraversalResult,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68pub struct WorkflowTraversalStepRecord {
69 pub step_index: usize,
70 pub node_id: String,
71 pub capability_id: String,
72 pub capability_version: String,
73 pub status: WorkflowTraversalStepStatus,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case")]
78pub enum WorkflowTraversalStepStatus {
79 Entered,
80 Completed,
81 Failed,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85pub struct WorkflowTraversalEdgeRecord {
86 pub edge_id: String,
87 pub from: String,
88 pub to: String,
89 pub trigger: WorkflowTraversalTrigger,
90 #[serde(default)]
91 pub event: Option<EventReference>,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95pub struct WaitingWorkflowEdgeContext {
96 pub workflow_execution_id: String,
97 pub edge_id: String,
98 pub from_node_id: String,
99 pub to_node_id: String,
100 pub event_ref: EventReference,
101 #[serde(default)]
102 pub predicate: Option<WorkflowEdgePredicate>,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct EventMatchRecord {
107 pub event_id: String,
108 pub event_version: String,
109 pub edge_id: String,
110 pub match_result: EventMatchResult,
111 #[serde(default)]
112 pub predicate_result: Option<EventPredicateResult>,
113 #[serde(default)]
114 pub rejection_reason: Option<String>,
115 pub recorded_at: String,
116 #[serde(default)]
120 pub subscription_id: Option<String>,
121 #[serde(default)]
124 pub cursor: Option<String>,
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(rename_all = "snake_case")]
129pub enum EventMatchResult {
130 Matched,
131 NotMatched,
132 AlreadyConsumed,
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "snake_case")]
137pub enum EventPredicateResult {
138 Passed,
139 Failed,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143pub struct EventWakeDecision {
144 pub decision_type: String,
145 pub event_id: String,
146 pub event_version: String,
147 pub edge_id: String,
148 pub workflow_execution_id: String,
149 pub wake_order: usize,
150 pub result: EventWakeDecisionResult,
151 pub recorded_at: String,
152 #[serde(default)]
155 pub subscription_id: Option<String>,
156 #[serde(default)]
159 pub cursor: Option<String>,
160}
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
163#[serde(rename_all = "snake_case")]
164pub enum EventWakeDecisionResult {
165 Taken,
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169pub struct EventConsumptionRecord {
170 pub event_id: String,
171 pub event_version: String,
172 pub edge_id: String,
173 pub workflow_execution_id: String,
174 pub consumed_at: String,
175 #[serde(default)]
179 pub subscription_id: Option<String>,
180 #[serde(default)]
183 pub cursor: Option<String>,
184}
185
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
187#[serde(rename_all = "snake_case")]
188pub enum WorkflowTraversalTrigger {
189 Direct,
190 Event,
191}
192
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194pub struct WorkflowTraversalResult {
195 pub status: WorkflowTraversalStatus,
196 #[serde(default)]
197 pub failure_reason: Option<WorkflowTraversalFailureReason>,
198}
199
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
201#[serde(rename_all = "snake_case")]
202pub enum WorkflowTraversalStatus {
203 Completed,
204 Error,
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
208#[serde(rename_all = "snake_case")]
209pub enum WorkflowTraversalFailureReason {
210 WorkflowNotFound,
211 WorkflowInvalid,
212 AmbiguousNextEdge,
213 MissingRequiredEvent,
214 TerminalNodeNotReached,
215 StepExecutionFailed,
216 EventSubscriptionFailed,
220 EventBrokerUnavailable,
224}
225
226#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
227pub struct WorkflowExecutionResult {
228 pub kind: String,
229 pub schema_version: String,
230 pub request_id: String,
231 pub workflow_id: String,
232 pub workflow_version: String,
233 pub status: WorkflowTraversalStatus,
234 #[serde(default)]
235 pub output: Option<Value>,
236 #[serde(default)]
237 pub error: Option<RuntimeError>,
238 #[serde(default)]
239 pub warnings: Vec<RuntimeWarning>,
240}
241
242#[derive(Debug, Clone, PartialEq, Eq)]
243pub struct WorkflowExecutionOutcome {
244 pub result: WorkflowExecutionResult,
245 pub evidence: WorkflowTraversalEvidence,
246}
247
248#[derive(Debug, Clone, PartialEq, Eq)]
249struct EmittedEventRecord {
250 record_id: String,
251 event: EventReference,
252 payload: Option<Value>,
253 source: Option<BrokerEventSource>,
256}
257
258#[derive(Debug, Clone, PartialEq, Eq)]
259struct BrokerEventSource {
260 subscription_id: String,
261 cursor: String,
262}
263
264#[derive(Debug, Clone, Default, PartialEq, Eq)]
265struct WorkflowEventEvidenceBundle {
266 waiting_edges: Vec<WaitingWorkflowEdgeContext>,
267 match_records: Vec<EventMatchRecord>,
268 wake_decisions: Vec<EventWakeDecision>,
269 consumptions: Vec<EventConsumptionRecord>,
270}
271
272#[derive(Debug, Clone, Default, PartialEq, Eq)]
273struct EventDrivenEvaluationOutcome {
274 taken_edge_ids: Vec<String>,
275 evidence: WorkflowEventEvidenceBundle,
276}
277
278impl<E> Runtime<E>
279where
280 E: LocalExecutor,
281{
282 #[must_use]
283 #[allow(clippy::needless_pass_by_value)]
284 pub fn execute_workflow(&self, request: WorkflowExecutionRequest) -> WorkflowExecutionOutcome {
285 if let Some(error) = validate_workflow_request(&request) {
286 return workflow_failure(
287 &request,
288 WorkflowTraversalFailureReason::WorkflowInvalid,
289 error,
290 Vec::new(),
291 Vec::new(),
292 Vec::new(),
293 WorkflowEventEvidenceBundle::default(),
294 Vec::new(),
295 );
296 }
297
298 let lookup_scope = map_workflow_lookup_scope(request.scope);
299 let Some(workflow) = self.workflow_registry.find_exact(
300 lookup_scope,
301 &request.workflow_id,
302 &request.workflow_version,
303 ) else {
304 return workflow_failure(
305 &request,
306 WorkflowTraversalFailureReason::WorkflowNotFound,
307 runtime_error(
308 RuntimeErrorCode::CapabilityNotFound,
309 "workflow definition was not found in the workflow registry",
310 json!({"workflow_id": request.workflow_id, "workflow_version": request.workflow_version}),
311 ),
312 Vec::new(),
313 Vec::new(),
314 Vec::new(),
315 WorkflowEventEvidenceBundle::default(),
316 Vec::new(),
317 );
318 };
319
320 if let Err(error) = validate_payload_against_contract(
321 &request.input,
322 &workflow.definition.inputs.schema,
323 RuntimeErrorCode::RequestInvalid,
324 "workflow request input does not satisfy the workflow input contract",
325 ) {
326 return workflow_failure(
327 &request,
328 WorkflowTraversalFailureReason::WorkflowInvalid,
329 error,
330 Vec::new(),
331 Vec::new(),
332 Vec::new(),
333 WorkflowEventEvidenceBundle::default(),
334 Vec::new(),
335 );
336 }
337
338 match self.traverse_workflow(&request, &workflow) {
339 Ok(success) => success,
340 Err(failure) => failure,
341 }
342 }
343
344 pub(crate) fn execute_workflow_capability(
345 &self,
346 mut context: crate::ExecutionContext,
347 selected: &ResolvedCapability,
348 started_execution: crate::StartedExecution,
349 ) -> RuntimeExecutionOutcome {
350 let Some(workflow_ref) = selected.artifact.workflow_ref.as_ref() else {
351 let error = runtime_error(
352 RuntimeErrorCode::ArtifactMissing,
353 "workflow-backed capability is missing its workflow reference",
354 json!({"artifact_ref": selected.record.artifact_ref}),
355 );
356 return execution_failure_outcome(
357 context,
358 ExecutionFailureState {
359 artifact_ref: selected.record.artifact_ref.clone(),
360 started_at: started_execution.started_at,
361 placement: started_execution.placement.clone(),
362 failure_reason: ExecutionFailureReason::ArtifactMissing,
363 },
364 error,
365 Vec::new(),
366 None,
367 );
368 };
369
370 let workflow_scope = match selected.record.scope {
371 RegistryScope::Public => WorkflowLookupScope::PublicOnly,
372 RegistryScope::Private => WorkflowLookupScope::PreferPrivate,
373 };
374 let workflow = self.execute_workflow(WorkflowExecutionRequest {
375 kind: WORKFLOW_REQUEST_KIND.to_string(),
376 schema_version: WORKFLOW_SCHEMA_VERSION.to_string(),
377 request_id: context.attempt.request.request_id.clone(),
378 workflow_id: workflow_ref.workflow_id.clone(),
379 workflow_version: workflow_ref.workflow_version.clone(),
380 scope: workflow_scope,
381 input: context.attempt.request.input.clone(),
382 governing_spec: WORKFLOW_GOVERNING_SPEC.to_string(),
383 });
384 context
385 .attempt
386 .warnings
387 .extend(workflow.result.warnings.iter().cloned());
388
389 match workflow.result.status {
390 WorkflowTraversalStatus::Completed => {
391 let output = workflow.result.output.unwrap_or(Value::Object(Map::new()));
392 let workflow_evidence = workflow.evidence;
393 let emitted_events = workflow_evidence.emitted_events.clone();
394 successful_execution_outcome(
395 context,
396 selected,
397 started_execution,
398 output,
399 emitted_events,
400 Some(workflow_evidence),
401 )
402 }
403 WorkflowTraversalStatus::Error => {
404 let workflow_evidence = workflow.evidence;
405 let emitted_events = workflow_evidence.emitted_events.clone();
406 execution_failure_outcome(
407 context,
408 ExecutionFailureState {
409 artifact_ref: selected.record.artifact_ref.clone(),
410 started_at: started_execution.started_at,
411 placement: started_execution.placement,
412 failure_reason: ExecutionFailureReason::ExecutionFailed,
413 },
414 workflow.result.error.unwrap_or(runtime_error(
415 RuntimeErrorCode::ExecutionFailed,
416 "workflow-backed capability execution failed",
417 json!({}),
418 )),
419 emitted_events,
420 Some(workflow_evidence),
421 )
422 }
423 }
424 }
425
426 #[allow(clippy::result_large_err, clippy::too_many_lines)]
427 fn traverse_workflow(
428 &self,
429 request: &WorkflowExecutionRequest,
430 workflow: &ResolvedWorkflow,
431 ) -> Result<WorkflowExecutionOutcome, WorkflowExecutionOutcome> {
432 let mut state = workflow_state(&request.input);
433 let mut current = workflow.definition.start_node.clone();
434 let mut step_index = 0;
435 let mut visited = Vec::new();
436 let mut traversed = Vec::new();
437 let mut emitted = Vec::new();
438 let mut event_evidence = WorkflowEventEvidenceBundle::default();
439 let mut consumed_event_edges = BTreeSet::new();
440 let mut warnings: Vec<RuntimeWarning> = Vec::new();
441 let workflow_execution_id = format!("workflow_exec_{}", request.request_id);
442
443 loop {
444 let Some(node) = workflow
445 .definition
446 .nodes
447 .iter()
448 .find(|node| node.node_id == current)
449 else {
450 return Err(workflow_failure(
451 request,
452 WorkflowTraversalFailureReason::WorkflowInvalid,
453 runtime_error(
454 RuntimeErrorCode::ExecutionFailed,
455 "workflow node could not be resolved during traversal",
456 json!({"node_id": current}),
457 ),
458 visited,
459 traversed,
460 emitted,
461 event_evidence,
462 warnings,
463 ));
464 };
465
466 visited.push(WorkflowTraversalStepRecord {
467 step_index,
468 node_id: node.node_id.clone(),
469 capability_id: node.capability_id.clone(),
470 capability_version: node.capability_version.clone(),
471 status: WorkflowTraversalStepStatus::Entered,
472 });
473
474 let lookup_scope = map_workflow_lookup_scope(request.scope);
475 let Some(capability) = self.registry.find_exact(
476 lookup_scope,
477 &node.capability_id,
478 &node.capability_version,
479 ) else {
480 return Err(workflow_failure(
481 request,
482 WorkflowTraversalFailureReason::WorkflowInvalid,
483 runtime_error(
484 RuntimeErrorCode::CapabilityNotFound,
485 "workflow node capability was not found in the capability registry",
486 json!({"capability_id": node.capability_id, "capability_version": node.capability_version}),
487 ),
488 visited,
489 traversed,
490 emitted,
491 event_evidence,
492 warnings,
493 ));
494 };
495
496 let artifact_bytes = match crate::load_artifact_bytes_for_verification(&capability) {
501 Ok(bytes) => bytes,
502 Err(error) => {
503 let mut failed = visited;
504 if let Some(last) = failed.last_mut() {
505 last.status = WorkflowTraversalStepStatus::Failed;
506 }
507 return Err(workflow_failure(
508 request,
509 WorkflowTraversalFailureReason::StepExecutionFailed,
510 error,
511 failed,
512 traversed,
513 emitted,
514 event_evidence,
515 warnings,
516 ));
517 }
518 };
519 match verify_artifact(&capability, &artifact_bytes, &self.security) {
520 Ok(record) => {
521 if let Some(code) = record.warning_code {
522 warnings.push(RuntimeWarning {
523 code,
524 message:
525 "unsigned local/dev artifact allowed by development security mode"
526 .to_string(),
527 });
528 }
529 }
530 Err(failure) => {
531 let mut failed = visited;
532 if let Some(last) = failed.last_mut() {
533 last.status = WorkflowTraversalStepStatus::Failed;
534 }
535 return Err(workflow_failure(
536 request,
537 WorkflowTraversalFailureReason::StepExecutionFailed,
538 runtime_error(
539 RuntimeErrorCode::ContractViolation,
540 "artifact signature verification failed before workflow step execution",
541 json!({
542 "code": failure.code(),
543 "artifact_verification": failure.record(),
544 "node_id": node.node_id,
545 }),
546 ),
547 failed,
548 traversed,
549 emitted,
550 event_evidence,
551 warnings,
552 ));
553 }
554 }
555
556 let node_input = node_input(&state, node);
557 if let Err(error) = validate_payload_against_contract(
558 &node_input,
559 &capability.contract.inputs.schema,
560 RuntimeErrorCode::RequestInvalid,
561 "workflow node input does not satisfy the capability input contract",
562 ) {
563 let mut failed = visited;
564 if let Some(last) = failed.last_mut() {
565 last.status = WorkflowTraversalStepStatus::Failed;
566 }
567 return Err(workflow_failure(
568 request,
569 WorkflowTraversalFailureReason::StepExecutionFailed,
570 error,
571 failed,
572 traversed,
573 emitted,
574 event_evidence,
575 warnings,
576 ));
577 }
578
579 let output = match self.executor.execute(&capability, &node_input) {
580 Ok(output) => output,
581 Err(failure) => {
582 let mut failed = visited;
583 if let Some(last) = failed.last_mut() {
584 last.status = WorkflowTraversalStepStatus::Failed;
585 }
586 return Err(workflow_failure(
587 request,
588 WorkflowTraversalFailureReason::StepExecutionFailed,
589 runtime_error(
590 RuntimeErrorCode::ExecutionFailed,
591 &failure.message,
592 json!({"code": format!("{:?}", failure.code)}),
593 ),
594 failed,
595 traversed,
596 emitted,
597 event_evidence,
598 warnings,
599 ));
600 }
601 };
602
603 if let Err(error) = validate_payload_against_contract(
604 &output.value,
605 &capability.contract.outputs.schema,
606 RuntimeErrorCode::OutputValidationFailed,
607 "workflow node output does not satisfy the capability output contract",
608 ) {
609 let mut failed = visited;
610 if let Some(last) = failed.last_mut() {
611 last.status = WorkflowTraversalStepStatus::Failed;
612 }
613 return Err(workflow_failure(
614 request,
615 WorkflowTraversalFailureReason::StepExecutionFailed,
616 error,
617 failed,
618 traversed,
619 emitted,
620 event_evidence,
621 warnings,
622 ));
623 }
624
625 if let Err(validation_message) = crate::validate_natively_emitted_events(
629 &capability.contract,
630 &output.emitted_events,
631 ) {
632 let mut failed = visited;
633 if let Some(last) = failed.last_mut() {
634 last.status = WorkflowTraversalStepStatus::Failed;
635 }
636 return Err(workflow_failure(
637 request,
638 WorkflowTraversalFailureReason::StepExecutionFailed,
639 runtime_error(
640 RuntimeErrorCode::ContractViolation,
641 &validation_message,
642 json!({"node_id": node.node_id}),
643 ),
644 failed,
645 traversed,
646 emitted,
647 event_evidence,
648 warnings,
649 ));
650 }
651
652 update_state(&mut state, node, &output.value);
653
654 if capability.contract.service_type == ServiceType::Subscribable {
660 for event in &output.emitted_events {
661 let _ = self.event_broker.publish(event.clone());
662 }
663 }
664
665 let node_emitted: Vec<EmittedEventRecord> = output
670 .emitted_events
671 .iter()
672 .enumerate()
673 .map(|(index, event)| EmittedEventRecord {
674 record_id: format!("event_record_{index}"),
675 event: EventReference {
676 event_id: event.event_type.clone(),
677 version: event.version.clone(),
678 },
679 payload: Some(event.data.clone()),
680 source: None,
681 })
682 .collect();
683 emitted.extend(node_emitted.iter().map(|record| record.event.clone()));
684 if let Some(last) = visited.last_mut() {
685 last.status = WorkflowTraversalStepStatus::Completed;
686 }
687
688 let outgoing = workflow
689 .definition
690 .edges
691 .iter()
692 .filter(|edge| edge.from == node.node_id)
693 .cloned()
694 .collect::<Vec<_>>();
695 let direct = outgoing
696 .iter()
697 .filter(|edge| edge.trigger == WorkflowEdgeTrigger::Direct)
698 .cloned()
699 .collect::<Vec<_>>();
700 if direct.len() > 1 {
701 return Err(workflow_failure(
702 request,
703 WorkflowTraversalFailureReason::AmbiguousNextEdge,
704 runtime_error(
705 RuntimeErrorCode::ExecutionFailed,
706 "workflow traversal found more than one direct next edge",
707 json!({"node_id": node.node_id}),
708 ),
709 visited,
710 traversed,
711 emitted,
712 event_evidence,
713 warnings,
714 ));
715 }
716 if let Some(edge) = direct.into_iter().next() {
717 traversed.push(edge_record(&edge));
718 current = edge.to;
719 step_index += 1;
720 continue;
721 }
722
723 let waiting_edges = waiting_edge_contexts(
724 &workflow_execution_id,
725 outgoing
726 .iter()
727 .filter(|edge| edge.trigger == WorkflowEdgeTrigger::Event)
728 .cloned()
729 .collect::<Vec<_>>()
730 .as_slice(),
731 );
732 if !waiting_edges.is_empty() {
733 event_evidence.waiting_edges.extend(waiting_edges.clone());
734 }
735 let local_evaluation = evaluate_event_driven_edges(
739 &waiting_edges,
740 &node_emitted,
741 &mut consumed_event_edges,
742 &format!("{}:step:{step_index}:local", request.request_id),
743 );
744 event_evidence
745 .match_records
746 .extend(local_evaluation.evidence.match_records.iter().cloned());
747 event_evidence
748 .wake_decisions
749 .extend(local_evaluation.evidence.wake_decisions.iter().cloned());
750 event_evidence
751 .consumptions
752 .extend(local_evaluation.evidence.consumptions.iter().cloned());
753 let mut taken_edge_ids = local_evaluation.taken_edge_ids;
754
755 let unresolved_edges: Vec<WaitingWorkflowEdgeContext> = waiting_edges
763 .iter()
764 .filter(|edge| !taken_edge_ids.contains(&edge.edge_id))
765 .cloned()
766 .collect();
767 if !unresolved_edges.is_empty() {
768 let broker = self.event_broker.as_ref();
769 match poll_broker_events_for_waiting_edges(broker, &unresolved_edges) {
770 Ok((broker_events, broker_warnings)) => {
771 warnings.extend(broker_warnings);
772 let broker_evaluation = evaluate_event_driven_edges(
773 &unresolved_edges,
774 &broker_events,
775 &mut consumed_event_edges,
776 &format!("{}:step:{step_index}:broker", request.request_id),
777 );
778 event_evidence
779 .match_records
780 .extend(broker_evaluation.evidence.match_records.iter().cloned());
781 event_evidence
782 .wake_decisions
783 .extend(broker_evaluation.evidence.wake_decisions.iter().cloned());
784 event_evidence
785 .consumptions
786 .extend(broker_evaluation.evidence.consumptions.iter().cloned());
787 taken_edge_ids.extend(broker_evaluation.taken_edge_ids);
788 }
789 Err(BrokerQueryFailure::UnregisteredEventType(event_type)) => {
790 let mut failed = visited;
791 if let Some(last) = failed.last_mut() {
792 last.status = WorkflowTraversalStepStatus::Failed;
793 }
794 return Err(workflow_failure(
795 request,
796 WorkflowTraversalFailureReason::EventSubscriptionFailed,
797 runtime_error(
798 RuntimeErrorCode::RequestInvalid,
799 "waiting edge references an event type not registered in EventBroker's catalog",
800 json!({"node_id": node.node_id, "event_type": event_type}),
801 ),
802 failed,
803 traversed,
804 emitted,
805 event_evidence,
806 warnings,
807 ));
808 }
809 Err(BrokerQueryFailure::BrokerUnavailable(detail)) => {
810 let mut failed = visited;
811 if let Some(last) = failed.last_mut() {
812 last.status = WorkflowTraversalStepStatus::Failed;
813 }
814 return Err(workflow_failure(
815 request,
816 WorkflowTraversalFailureReason::EventBrokerUnavailable,
817 runtime_error(
818 RuntimeErrorCode::ExecutionFailed,
819 "EventBroker was unreachable or internally failing while evaluating a waiting event-driven edge",
820 json!({"node_id": node.node_id, "detail": detail}),
821 ),
822 failed,
823 traversed,
824 emitted,
825 event_evidence,
826 warnings,
827 ));
828 }
829 }
830 }
831 let matched_event_edges = outgoing
832 .iter()
833 .filter(|edge| {
834 taken_edge_ids
835 .iter()
836 .any(|edge_id| edge_id == &edge.edge_id)
837 })
838 .cloned()
839 .collect::<Vec<_>>();
840 if matched_event_edges.len() > 1 {
841 return Err(workflow_failure(
842 request,
843 WorkflowTraversalFailureReason::AmbiguousNextEdge,
844 runtime_error(
845 RuntimeErrorCode::ExecutionFailed,
846 "workflow traversal found more than one event next edge",
847 json!({"node_id": node.node_id}),
848 ),
849 visited,
850 traversed,
851 emitted,
852 event_evidence,
853 warnings,
854 ));
855 }
856 if let Some(edge) = matched_event_edges.into_iter().next() {
857 traversed.push(edge_record(&edge));
858 current = edge.to;
859 step_index += 1;
860 continue;
861 }
862
863 if workflow.definition.terminal_nodes.contains(&node.node_id) {
864 let final_output =
865 final_workflow_output(&state, &workflow.definition.output_projection);
866 if let Err(error) = validate_payload_against_contract(
867 &final_output,
868 &workflow.definition.outputs.schema,
869 RuntimeErrorCode::OutputValidationFailed,
870 "workflow output does not satisfy the workflow output contract",
871 ) {
872 return Err(workflow_failure(
873 request,
874 WorkflowTraversalFailureReason::WorkflowInvalid,
875 error,
876 visited,
877 traversed,
878 emitted,
879 event_evidence,
880 warnings,
881 ));
882 }
883
884 let evidence = WorkflowTraversalEvidence {
885 kind: WORKFLOW_EVIDENCE_KIND.to_string(),
886 schema_version: WORKFLOW_SCHEMA_VERSION.to_string(),
887 trace_id: format!("workflow_trace_{}", request.request_id),
888 request_id: request.request_id.clone(),
889 workflow_id: workflow.definition.id.clone(),
890 workflow_version: workflow.definition.version.clone(),
891 governing_spec: WORKFLOW_GOVERNING_SPEC.to_string(),
892 visited_nodes: visited,
893 traversed_edges: traversed,
894 emitted_events: emitted,
895 waiting_edges: event_evidence.waiting_edges,
896 event_match_records: event_evidence.match_records,
897 event_wake_decisions: event_evidence.wake_decisions,
898 event_consumptions: event_evidence.consumptions,
899 result: WorkflowTraversalResult {
900 status: WorkflowTraversalStatus::Completed,
901 failure_reason: None,
902 },
903 };
904
905 return Ok(WorkflowExecutionOutcome {
906 result: WorkflowExecutionResult {
907 kind: WORKFLOW_REQUEST_KIND.to_string(),
908 schema_version: WORKFLOW_SCHEMA_VERSION.to_string(),
909 request_id: request.request_id.clone(),
910 workflow_id: workflow.definition.id.clone(),
911 workflow_version: workflow.definition.version.clone(),
912 status: WorkflowTraversalStatus::Completed,
913 output: Some(final_output),
914 error: None,
915 warnings,
916 },
917 evidence,
918 });
919 }
920
921 let failure_reason = if outgoing
922 .iter()
923 .any(|edge| edge.trigger == WorkflowEdgeTrigger::Event)
924 {
925 WorkflowTraversalFailureReason::MissingRequiredEvent
926 } else {
927 WorkflowTraversalFailureReason::TerminalNodeNotReached
928 };
929
930 return Err(workflow_failure(
931 request,
932 failure_reason,
933 runtime_error(
934 RuntimeErrorCode::ExecutionFailed,
935 "workflow traversal could not reach a valid next node",
936 json!({"node_id": node.node_id}),
937 ),
938 visited,
939 traversed,
940 emitted,
941 event_evidence,
942 warnings,
943 ));
944 }
945 }
946}
947
948fn validate_workflow_request(request: &WorkflowExecutionRequest) -> Option<RuntimeError> {
949 if request.kind != WORKFLOW_REQUEST_KIND {
950 return Some(runtime_error(
951 RuntimeErrorCode::RequestInvalid,
952 "kind must equal workflow_execution_request",
953 json!({"path": "$.kind"}),
954 ));
955 }
956 if request.schema_version != WORKFLOW_SCHEMA_VERSION {
957 return Some(runtime_error(
958 RuntimeErrorCode::RequestInvalid,
959 "schema_version must equal 1.0.0",
960 json!({"path": "$.schema_version"}),
961 ));
962 }
963 if request.governing_spec != WORKFLOW_GOVERNING_SPEC {
964 return Some(runtime_error(
965 RuntimeErrorCode::RequestInvalid,
966 "governing_spec must equal 007-workflow-registry-traversal",
967 json!({"path": "$.governing_spec"}),
968 ));
969 }
970 if request.request_id.trim().is_empty()
971 || request.workflow_id.trim().is_empty()
972 || request.workflow_version.trim().is_empty()
973 {
974 return Some(runtime_error(
975 RuntimeErrorCode::RequestInvalid,
976 "request_id, workflow_id, and workflow_version must be non-empty",
977 json!({"path": "$"}),
978 ));
979 }
980 None
981}
982
983fn map_workflow_lookup_scope(scope: WorkflowLookupScope) -> LookupScope {
984 match scope {
985 WorkflowLookupScope::PublicOnly => LookupScope::PublicOnly,
986 WorkflowLookupScope::PreferPrivate => LookupScope::PreferPrivate,
987 }
988}
989
990fn workflow_state(input: &Value) -> Map<String, Value> {
991 match input {
992 Value::Object(map) => map.clone(),
993 other => {
994 let mut map = Map::new();
995 map.insert("input".to_string(), other.clone());
996 map
997 }
998 }
999}
1000
1001fn node_input(state: &Map<String, Value>, node: &WorkflowNode) -> Value {
1002 let mut input = Map::new();
1003 for key in &node.input.from_workflow_input {
1004 if let Some(value) = state.get(key) {
1005 input.insert(key.clone(), value.clone());
1006 }
1007 }
1008 Value::Object(input)
1009}
1010
1011fn update_state(state: &mut Map<String, Value>, node: &WorkflowNode, output: &Value) {
1012 let Value::Object(object) = output else {
1013 return;
1014 };
1015 for key in &node.output.to_workflow_state {
1016 if let Some(value) = object.get(key) {
1017 state.insert(key.clone(), value.clone());
1018 }
1019 }
1020 if let Some(namespace) = &node.output.publish_to_state_as {
1021 state.insert(namespace.clone(), output.clone());
1022 }
1023}
1024
1025fn final_workflow_output(state: &Map<String, Value>, output_projection: &[String]) -> Value {
1026 if output_projection.is_empty() {
1027 return Value::Object(state.clone());
1028 }
1029 let mut projected = Map::new();
1030 for key in output_projection {
1031 if let Some(value) = state.get(key) {
1032 projected.insert(key.clone(), value.clone());
1033 }
1034 }
1035 Value::Object(projected)
1036}
1037
1038fn waiting_edge_contexts(
1039 workflow_execution_id: &str,
1040 edges: &[WorkflowEdge],
1041) -> Vec<WaitingWorkflowEdgeContext> {
1042 edges
1043 .iter()
1044 .filter_map(|edge| {
1045 Some(WaitingWorkflowEdgeContext {
1046 workflow_execution_id: workflow_execution_id.to_string(),
1047 edge_id: edge.edge_id.clone(),
1048 from_node_id: edge.from.clone(),
1049 to_node_id: edge.to.clone(),
1050 event_ref: edge.event.clone()?,
1051 predicate: edge.predicate.clone(),
1052 })
1053 })
1054 .collect()
1055}
1056
1057#[derive(Debug, Clone, PartialEq, Eq)]
1060enum BrokerQueryFailure {
1061 UnregisteredEventType(String),
1064 BrokerUnavailable(String),
1066}
1067
1068fn poll_broker_events_for_waiting_edges(
1076 broker: &dyn EventBroker,
1077 waiting_edges: &[WaitingWorkflowEdgeContext],
1078) -> Result<(Vec<EmittedEventRecord>, Vec<RuntimeWarning>), BrokerQueryFailure> {
1079 let mut event_types: Vec<&str> = waiting_edges
1080 .iter()
1081 .map(|edge| edge.event_ref.event_id.as_str())
1082 .collect();
1083 event_types.sort_unstable();
1084 event_types.dedup();
1085
1086 let mut records = Vec::new();
1087 let mut warnings = Vec::new();
1088
1089 for event_type in event_types {
1090 let subscription = broker
1091 .subscribe(event_type, "0")
1092 .map_err(|error| match error {
1093 EventError::UnregisteredEventType(event_type) => {
1094 BrokerQueryFailure::UnregisteredEventType(event_type)
1095 }
1096 other => BrokerQueryFailure::BrokerUnavailable(other.to_string()),
1097 })?;
1098
1099 let mut delivered = Vec::new();
1100 let poll_failure = loop {
1101 match broker.poll(&subscription.subscription_id, EVENT_BROKER_POLL_BATCH) {
1102 Ok(batch) => {
1103 let batch_len = batch.events.len();
1104 delivered.extend(batch.events);
1105 if batch_len < EVENT_BROKER_POLL_BATCH {
1106 break None;
1107 }
1108 }
1109 Err(error) => break Some(BrokerQueryFailure::BrokerUnavailable(error.to_string())),
1110 }
1111 };
1112
1113 if let Err(cancel_error) = broker.cancel(&subscription.subscription_id) {
1114 warnings.push(RuntimeWarning {
1115 code: "event_broker_subscription_cleanup_failed".to_string(),
1116 message: format!(
1117 "failed to cancel workflow event-driven edge subscription {}: {cancel_error}",
1118 subscription.subscription_id
1119 ),
1120 });
1121 }
1122
1123 if let Some(failure) = poll_failure {
1124 return Err(failure);
1125 }
1126
1127 for broker_event in delivered {
1128 records.push(EmittedEventRecord {
1129 record_id: format!("broker:{event_type}:{}", broker_event.cursor),
1130 event: EventReference {
1131 event_id: event_type.to_string(),
1132 version: broker_event.event.version.clone(),
1133 },
1134 payload: Some(broker_event.event.data.clone()),
1135 source: Some(BrokerEventSource {
1136 subscription_id: subscription.subscription_id.clone(),
1137 cursor: broker_event.cursor.clone(),
1138 }),
1139 });
1140 }
1141 }
1142
1143 Ok((records, warnings))
1144}
1145
1146#[allow(clippy::too_many_lines)]
1147fn evaluate_event_driven_edges(
1148 waiting_edges: &[WaitingWorkflowEdgeContext],
1149 emitted_events: &[EmittedEventRecord],
1150 consumed_event_edges: &mut BTreeSet<String>,
1151 record_prefix: &str,
1152) -> EventDrivenEvaluationOutcome {
1153 let mut ordered_waiting_edges = waiting_edges.to_vec();
1154 ordered_waiting_edges.sort_by(|left, right| {
1155 left.workflow_execution_id
1156 .cmp(&right.workflow_execution_id)
1157 .then_with(|| left.edge_id.cmp(&right.edge_id))
1158 });
1159
1160 let mut outcome = EventDrivenEvaluationOutcome::default();
1161 if emitted_events.is_empty() {
1162 outcome
1163 .evidence
1164 .match_records
1165 .extend(ordered_waiting_edges.iter().map(|edge| EventMatchRecord {
1166 event_id: edge.event_ref.event_id.clone(),
1167 event_version: edge.event_ref.version.clone(),
1168 edge_id: edge.edge_id.clone(),
1169 match_result: EventMatchResult::NotMatched,
1170 predicate_result: None,
1171 rejection_reason: Some("required event was not emitted".to_string()),
1172 recorded_at: format!("{record_prefix}:no_event:{}", edge.edge_id),
1173 subscription_id: None,
1174 cursor: None,
1175 }));
1176 return outcome;
1177 }
1178
1179 let mut wake_order = 1;
1180 for (event_index, emitted_event) in emitted_events.iter().enumerate() {
1181 let subscription_id = emitted_event
1182 .source
1183 .as_ref()
1184 .map(|source| source.subscription_id.clone());
1185 let cursor = emitted_event
1186 .source
1187 .as_ref()
1188 .map(|source| source.cursor.clone());
1189 for waiting_edge in &ordered_waiting_edges {
1190 let match_recorded_at = format!(
1191 "{record_prefix}:event:{event_index}:match:{}",
1192 waiting_edge.edge_id
1193 );
1194 if emitted_event.event != waiting_edge.event_ref {
1195 outcome.evidence.match_records.push(EventMatchRecord {
1196 event_id: emitted_event.event.event_id.clone(),
1197 event_version: emitted_event.event.version.clone(),
1198 edge_id: waiting_edge.edge_id.clone(),
1199 match_result: EventMatchResult::NotMatched,
1200 predicate_result: None,
1201 rejection_reason: Some(
1202 "event id/version did not match the waiting edge".to_string(),
1203 ),
1204 recorded_at: match_recorded_at,
1205 subscription_id: subscription_id.clone(),
1206 cursor: cursor.clone(),
1207 });
1208 continue;
1209 }
1210
1211 if let Some(predicate) = waiting_edge.predicate.as_ref() {
1212 let predicate_passed =
1213 event_payload_field(emitted_event.payload.as_ref(), &predicate.field)
1214 .is_some_and(|value| value == &predicate.equals);
1215 if !predicate_passed {
1216 outcome.evidence.match_records.push(EventMatchRecord {
1217 event_id: emitted_event.event.event_id.clone(),
1218 event_version: emitted_event.event.version.clone(),
1219 edge_id: waiting_edge.edge_id.clone(),
1220 match_result: EventMatchResult::NotMatched,
1221 predicate_result: Some(EventPredicateResult::Failed),
1222 rejection_reason: Some(
1223 "event predicate did not match the emitted payload".to_string(),
1224 ),
1225 recorded_at: match_recorded_at,
1226 subscription_id: subscription_id.clone(),
1227 cursor: cursor.clone(),
1228 });
1229 continue;
1230 }
1231 }
1232
1233 let consumption_key = format!(
1234 "{}|{}|{}",
1235 emitted_event.record_id, waiting_edge.workflow_execution_id, waiting_edge.edge_id
1236 );
1237 if consumed_event_edges.contains(&consumption_key) {
1238 outcome.evidence.match_records.push(EventMatchRecord {
1239 event_id: emitted_event.event.event_id.clone(),
1240 event_version: emitted_event.event.version.clone(),
1241 edge_id: waiting_edge.edge_id.clone(),
1242 match_result: EventMatchResult::AlreadyConsumed,
1243 predicate_result: waiting_edge
1244 .predicate
1245 .as_ref()
1246 .map(|_| EventPredicateResult::Passed),
1247 rejection_reason: Some(
1248 "event record was already consumed for this waiting edge".to_string(),
1249 ),
1250 recorded_at: match_recorded_at,
1251 subscription_id: subscription_id.clone(),
1252 cursor: cursor.clone(),
1253 });
1254 continue;
1255 }
1256
1257 consumed_event_edges.insert(consumption_key);
1258 outcome.evidence.match_records.push(EventMatchRecord {
1259 event_id: emitted_event.event.event_id.clone(),
1260 event_version: emitted_event.event.version.clone(),
1261 edge_id: waiting_edge.edge_id.clone(),
1262 match_result: EventMatchResult::Matched,
1263 predicate_result: waiting_edge
1264 .predicate
1265 .as_ref()
1266 .map(|_| EventPredicateResult::Passed),
1267 rejection_reason: None,
1268 recorded_at: match_recorded_at.clone(),
1269 subscription_id: subscription_id.clone(),
1270 cursor: cursor.clone(),
1271 });
1272 outcome.taken_edge_ids.push(waiting_edge.edge_id.clone());
1273 let wake_recorded_at = format!(
1274 "{record_prefix}:event:{event_index}:wake:{}",
1275 waiting_edge.edge_id
1276 );
1277 outcome.evidence.wake_decisions.push(EventWakeDecision {
1278 decision_type: "event_wake".to_string(),
1279 event_id: emitted_event.event.event_id.clone(),
1280 event_version: emitted_event.event.version.clone(),
1281 edge_id: waiting_edge.edge_id.clone(),
1282 workflow_execution_id: waiting_edge.workflow_execution_id.clone(),
1283 wake_order,
1284 result: EventWakeDecisionResult::Taken,
1285 recorded_at: wake_recorded_at.clone(),
1286 subscription_id: subscription_id.clone(),
1287 cursor: cursor.clone(),
1288 });
1289 outcome.evidence.consumptions.push(EventConsumptionRecord {
1290 event_id: emitted_event.event.event_id.clone(),
1291 event_version: emitted_event.event.version.clone(),
1292 edge_id: waiting_edge.edge_id.clone(),
1293 workflow_execution_id: waiting_edge.workflow_execution_id.clone(),
1294 consumed_at: wake_recorded_at,
1295 subscription_id: subscription_id.clone(),
1296 cursor: cursor.clone(),
1297 });
1298 wake_order += 1;
1299 }
1300 }
1301 outcome
1302}
1303
1304fn event_payload_field<'a>(payload: Option<&'a Value>, field: &str) -> Option<&'a Value> {
1305 let payload = payload?;
1306 let path = field.strip_prefix("payload.").unwrap_or(field);
1307 if path == "payload" || path.is_empty() {
1308 return Some(payload);
1309 }
1310
1311 let mut current = payload;
1312 for segment in path.split('.') {
1313 let Value::Object(map) = current else {
1314 return None;
1315 };
1316 current = map.get(segment)?;
1317 }
1318 Some(current)
1319}
1320
1321fn edge_record(edge: &WorkflowEdge) -> WorkflowTraversalEdgeRecord {
1322 WorkflowTraversalEdgeRecord {
1323 edge_id: edge.edge_id.clone(),
1324 from: edge.from.clone(),
1325 to: edge.to.clone(),
1326 trigger: match edge.trigger {
1327 WorkflowEdgeTrigger::Direct => WorkflowTraversalTrigger::Direct,
1328 WorkflowEdgeTrigger::Event => WorkflowTraversalTrigger::Event,
1329 },
1330 event: edge.event.clone(),
1331 }
1332}
1333
1334#[allow(clippy::too_many_arguments)]
1335fn workflow_failure(
1336 request: &WorkflowExecutionRequest,
1337 failure_reason: WorkflowTraversalFailureReason,
1338 error: RuntimeError,
1339 visited_nodes: Vec<WorkflowTraversalStepRecord>,
1340 traversed_edges: Vec<WorkflowTraversalEdgeRecord>,
1341 emitted_events: Vec<EventReference>,
1342 event_evidence: WorkflowEventEvidenceBundle,
1343 warnings: Vec<RuntimeWarning>,
1344) -> WorkflowExecutionOutcome {
1345 let evidence = WorkflowTraversalEvidence {
1346 kind: WORKFLOW_EVIDENCE_KIND.to_string(),
1347 schema_version: WORKFLOW_SCHEMA_VERSION.to_string(),
1348 trace_id: format!("workflow_trace_{}", request.request_id),
1349 request_id: request.request_id.clone(),
1350 workflow_id: request.workflow_id.clone(),
1351 workflow_version: request.workflow_version.clone(),
1352 governing_spec: WORKFLOW_GOVERNING_SPEC.to_string(),
1353 visited_nodes,
1354 traversed_edges,
1355 emitted_events,
1356 waiting_edges: event_evidence.waiting_edges,
1357 event_match_records: event_evidence.match_records,
1358 event_wake_decisions: event_evidence.wake_decisions,
1359 event_consumptions: event_evidence.consumptions,
1360 result: WorkflowTraversalResult {
1361 status: WorkflowTraversalStatus::Error,
1362 failure_reason: Some(failure_reason),
1363 },
1364 };
1365
1366 WorkflowExecutionOutcome {
1367 result: WorkflowExecutionResult {
1368 kind: WORKFLOW_REQUEST_KIND.to_string(),
1369 schema_version: WORKFLOW_SCHEMA_VERSION.to_string(),
1370 request_id: request.request_id.clone(),
1371 workflow_id: request.workflow_id.clone(),
1372 workflow_version: request.workflow_version.clone(),
1373 status: WorkflowTraversalStatus::Error,
1374 output: None,
1375 error: Some(error),
1376 warnings,
1377 },
1378 evidence,
1379 }
1380}
1381
1382#[cfg(test)]
1383mod tests {
1384 use super::*;
1385 use crate::events;
1386 use crate::events::InProcessBroker;
1387 use crate::security::RuntimeSecurityConfig;
1388 use crate::{
1389 CandidateCollectionRecord, LocalExecutionFailure, LocalExecutionFailureCode,
1390 LocalExecutionOutput, RuntimeContext, RuntimeIntent, RuntimeLookup, RuntimeLookupScope,
1391 RuntimeRequest, RuntimeResultStatus, SelectionRecord,
1392 };
1393 use serde_json::json;
1394 use std::sync::Arc;
1395 use traverse_contracts::{
1396 BinaryFormat as ContractBinaryFormat, CapabilityContract, Condition, Entrypoint,
1397 EntrypointKind, EventReference, EvidenceStatus, EvidenceType, Execution,
1398 ExecutionConstraints, ExecutionTarget, FilesystemAccess, HostApiAccess, IdReference,
1399 Lifecycle, NetworkAccess, Owner, Provenance, ProvenanceSource, SchemaContainer,
1400 ServiceType, SideEffect, SideEffectKind, ValidationEvidence,
1401 };
1402 use traverse_registry::{
1403 ArtifactDigests, ArtifactSignature, ArtifactSignatureScheme, BinaryFormat, BinaryReference,
1404 CapabilityArtifactRecord, CapabilityRegistration, CapabilityRegistry,
1405 ComposabilityMetadata, CompositionKind, CompositionPattern, ImplementationKind,
1406 RegistryProvenance, RegistryScope, SourceKind, SourceReference, WorkflowDefinition,
1407 WorkflowEdge, WorkflowEdgeTrigger, WorkflowNode, WorkflowNodeInput, WorkflowNodeOutput,
1408 WorkflowRegistration, WorkflowRegistry, WorkflowRegistryRecord, workflow_artifact_record,
1409 };
1410
1411 #[test]
1412 fn workflow_request_validation_rejects_invalid_guards() {
1413 let mut request = valid_workflow_request();
1414 request.kind = "bad".to_string();
1415 assert_eq!(
1416 validate_workflow_request(&request).map(|error| error.code),
1417 Some(RuntimeErrorCode::RequestInvalid)
1418 );
1419
1420 let mut request = valid_workflow_request();
1421 request.schema_version = "2.0.0".to_string();
1422 assert_eq!(
1423 validate_workflow_request(&request).map(|error| error.code),
1424 Some(RuntimeErrorCode::RequestInvalid)
1425 );
1426
1427 let mut request = valid_workflow_request();
1428 request.governing_spec = "bad".to_string();
1429 assert_eq!(
1430 validate_workflow_request(&request).map(|error| error.code),
1431 Some(RuntimeErrorCode::RequestInvalid)
1432 );
1433
1434 let mut request = valid_workflow_request();
1435 request.request_id.clear();
1436 assert_eq!(
1437 validate_workflow_request(&request).map(|error| error.code),
1438 Some(RuntimeErrorCode::RequestInvalid)
1439 );
1440 }
1441
1442 #[test]
1443 fn workflow_helpers_cover_state_and_edge_paths() {
1444 let scalar = workflow_state(&json!("value"));
1445 assert_eq!(scalar.get("input"), Some(&json!("value")));
1446
1447 let mut state = workflow_state(&json!({"comment_text": "hello"}));
1448 let node = WorkflowNode {
1449 node_id: "node".to_string(),
1450 capability_id: "content.comments.create-comment-draft".to_string(),
1451 capability_version: "1.0.0".to_string(),
1452 input: WorkflowNodeInput {
1453 from_workflow_input: vec!["comment_text".to_string(), "missing".to_string()],
1454 },
1455 output: WorkflowNodeOutput {
1456 to_workflow_state: vec!["draft_id".to_string()],
1457 publish_to_state_as: None,
1458 },
1459 };
1460 assert_eq!(node_input(&state, &node), json!({"comment_text": "hello"}));
1461 update_state(&mut state, &node, &json!({"draft_id": "draft-1"}));
1462 assert_eq!(state.get("draft_id"), Some(&json!("draft-1")));
1463 update_state(&mut state, &node, &json!("not-an-object"));
1464
1465 let edge = WorkflowEdge {
1466 edge_id: "edge".to_string(),
1467 from: "a".to_string(),
1468 to: "b".to_string(),
1469 trigger: WorkflowEdgeTrigger::Event,
1470 event: Some(EventReference {
1471 event_id: "content.comments.draft-created".to_string(),
1472 version: "1.0.0".to_string(),
1473 }),
1474 predicate: None,
1475 };
1476 assert_eq!(edge_record(&edge).trigger, WorkflowTraversalTrigger::Event);
1477 assert_eq!(
1478 map_workflow_lookup_scope(WorkflowLookupScope::PreferPrivate),
1479 LookupScope::PreferPrivate
1480 );
1481 assert_eq!(
1482 event_payload_field(Some(&json!({"severity": "normal"})), "payload.severity"),
1483 Some(&json!("normal"))
1484 );
1485 assert_eq!(
1486 event_payload_field(Some(&json!({"severity": "normal"})), "payload"),
1487 Some(&json!({"severity": "normal"}))
1488 );
1489 assert_eq!(
1490 event_payload_field(Some(&json!("normal")), "payload.severity"),
1491 None
1492 );
1493 }
1494
1495 #[test]
1496 #[allow(clippy::too_many_lines)]
1497 fn executes_workflow_deterministically_and_supports_workflow_backed_capabilities() {
1498 let workflow_registry = workflow_registry_fixture();
1499 let runtime = Runtime::new(capability_registry_fixture(), WorkflowExecutor)
1500 .with_workflow_registry(workflow_registry)
1501 .with_security_config(RuntimeSecurityConfig::development());
1502
1503 let workflow = runtime.execute_workflow(valid_workflow_request());
1504 assert_eq!(workflow.result.status, WorkflowTraversalStatus::Completed);
1505 assert_eq!(
1506 workflow.result.output,
1507 Some(
1508 json!({"comment_text": "hello", "draft_id": "draft-1", "comment_id": "comment-1"})
1509 )
1510 );
1511 assert_eq!(workflow.evidence.visited_nodes.len(), 3);
1512 assert_eq!(workflow.evidence.traversed_edges.len(), 2);
1513 assert_eq!(workflow.evidence.waiting_edges.len(), 2);
1514 assert_eq!(workflow.evidence.event_wake_decisions.len(), 2);
1515 assert_eq!(workflow.evidence.event_consumptions.len(), 2);
1516 assert!(
1517 workflow
1518 .evidence
1519 .event_match_records
1520 .iter()
1521 .all(|record| record.match_result == EventMatchResult::Matched)
1522 );
1523
1524 let mut composed_registry = capability_registry_fixture();
1525 register_capability_ok(
1526 &mut composed_registry,
1527 CapabilityRegistration {
1528 scope: RegistryScope::Public,
1529 contract: capability_contract(
1530 "content.comments.publish-comment",
1531 vec![],
1532 json!({
1533 "type": "object",
1534 "properties": { "comment_text": { "type": "string" } },
1535 "required": ["comment_text"],
1536 "additionalProperties": true
1537 }),
1538 json!({
1539 "type": "object",
1540 "properties": { "comment_id": { "type": "string" } },
1541 "required": ["comment_id"],
1542 "additionalProperties": true
1543 }),
1544 ),
1545 contract_path: "contracts/publish-comment.json".to_string(),
1546 artifact: workflow_artifact_record(
1547 "content.comments.publish-comment",
1548 "1.0.0",
1549 "artifact-workflow",
1550 ),
1551 registered_at: "2026-03-27T00:10:00Z".to_string(),
1552 tags: vec!["comments".to_string()],
1553 composability: ComposabilityMetadata {
1554 kind: CompositionKind::Composite,
1555 patterns: vec![
1556 CompositionPattern::Sequential,
1557 CompositionPattern::EventDriven,
1558 ],
1559 provides: vec!["published-comment".to_string()],
1560 requires: vec!["draft".to_string()],
1561 },
1562 governing_spec: "005-capability-registry".to_string(),
1563 validator_version: "validator".to_string(),
1564 },
1565 );
1566
1567 let runtime = Runtime::new(composed_registry, WorkflowExecutor)
1568 .with_workflow_registry(workflow_registry_fixture())
1569 .with_security_config(RuntimeSecurityConfig::development());
1570 let result = runtime.execute(RuntimeRequest {
1571 kind: "runtime_request".to_string(),
1572 schema_version: "1.0.0".to_string(),
1573 request_id: "request-workflow".to_string(),
1574 intent: RuntimeIntent {
1575 capability_id: Some("content.comments.publish-comment".to_string()),
1576 capability_version: Some("1.0.0".to_string()),
1577 version_range: None,
1578 intent_key: None,
1579 },
1580 input: json!({"comment_text": "hello"}),
1581 lookup: RuntimeLookup {
1582 scope: RuntimeLookupScope::PublicOnly,
1583 allow_ambiguity: false,
1584 },
1585 context: RuntimeContext {
1586 requested_target: crate::PlacementTarget::Local,
1587 correlation_id: None,
1588 caller: None,
1589 traceparent: None,
1590 tracestate: None,
1591 metadata: None,
1592 identity: None,
1593 },
1594 governing_spec: "006-runtime-request-execution".to_string(),
1595 });
1596 assert_eq!(result.result.status, RuntimeResultStatus::Completed);
1597 assert_eq!(
1598 result.result.output,
1599 Some(
1600 json!({"comment_text": "hello", "draft_id": "draft-1", "comment_id": "comment-1"})
1601 )
1602 );
1603 assert!(
1604 result
1605 .result
1606 .warnings
1607 .iter()
1608 .any(|warning| warning.code == "unsigned_local_dev_artifact")
1609 );
1610 }
1611
1612 #[test]
1619 fn workflow_node_emitted_events_are_published_to_event_broker() {
1620 let broker = event_catalog_broker_fixture("content.comments.draft-created", "1.0.0");
1621 let subscription = broker
1622 .subscribe("content.comments.draft-created", "0")
1623 .unwrap_or_else(|error| unreachable!("{error:?}"));
1624
1625 let workflow_registry = workflow_registry_fixture();
1626 let runtime = Runtime::new(capability_registry_fixture(), WorkflowExecutor)
1627 .with_workflow_registry(workflow_registry)
1628 .with_security_config(RuntimeSecurityConfig::development())
1629 .with_event_broker(broker.clone());
1630
1631 let workflow = runtime.execute_workflow(valid_workflow_request());
1632 assert_eq!(workflow.result.status, WorkflowTraversalStatus::Completed);
1633
1634 let poll = broker
1635 .poll(&subscription.subscription_id, 10)
1636 .unwrap_or_else(|error| unreachable!("{error:?}"));
1637 assert_eq!(poll.events.len(), 1);
1638 assert_eq!(
1639 poll.events[0].event.event_type,
1640 "content.comments.draft-created"
1641 );
1642 }
1643
1644 #[test]
1649 fn workflow_node_publish_failure_does_not_fail_workflow_step() {
1650 let workflow_registry = workflow_registry_fixture();
1651 let runtime = Runtime::new(capability_registry_fixture(), WorkflowExecutor)
1652 .with_workflow_registry(workflow_registry)
1653 .with_security_config(RuntimeSecurityConfig::development())
1654 .with_event_broker(Arc::new(AlwaysFailingBroker));
1655
1656 let workflow = runtime.execute_workflow(valid_workflow_request());
1657 assert_eq!(workflow.result.status, WorkflowTraversalStatus::Completed);
1658 }
1659
1660 #[test]
1661 fn workflow_failures_cover_not_found_missing_events_and_step_failures() {
1662 let workflow_registry = workflow_registry_fixture();
1663 let runtime = Runtime::new(capability_registry_fixture(), WorkflowExecutor)
1664 .with_workflow_registry(workflow_registry);
1665
1666 let mut missing_request = valid_workflow_request();
1667 missing_request.workflow_id = "missing".to_string();
1668 let missing = runtime.execute_workflow(missing_request);
1669 assert_eq!(
1670 missing.evidence.result.failure_reason,
1671 Some(WorkflowTraversalFailureReason::WorkflowNotFound)
1672 );
1673
1674 let workflow_registry = workflow_registry_fixture();
1675 let runtime = Runtime::new(capability_registry_fixture(), MissingEventWorkflowExecutor)
1681 .with_workflow_registry(workflow_registry)
1682 .with_security_config(RuntimeSecurityConfig::development())
1683 .with_event_broker(event_catalog_broker_fixture(
1684 "content.comments.validated",
1685 "1.0.0",
1686 ));
1687 let missing_event = runtime.execute_workflow(valid_workflow_request());
1688 assert_eq!(
1689 missing_event.evidence.result.failure_reason,
1690 Some(WorkflowTraversalFailureReason::MissingRequiredEvent)
1691 );
1692
1693 let runtime = Runtime::new(capability_registry_fixture(), FailingWorkflowExecutor)
1694 .with_workflow_registry(workflow_registry_fixture())
1695 .with_security_config(RuntimeSecurityConfig::development());
1696 let failed = runtime.execute_workflow(valid_workflow_request());
1697 assert_eq!(
1698 failed.evidence.result.failure_reason,
1699 Some(WorkflowTraversalFailureReason::StepExecutionFailed)
1700 );
1701 }
1702
1703 #[test]
1704 fn event_driven_helpers_are_deterministic_and_prevent_duplicate_consumption() {
1705 let event = EmittedEventRecord {
1706 record_id: "event_record_0".to_string(),
1707 event: EventReference {
1708 event_id: "content.comments.validated".to_string(),
1709 version: "1.0.0".to_string(),
1710 },
1711 payload: Some(json!({"severity": "normal"})),
1712 source: None,
1713 };
1714 let waiting_edges = vec![
1715 WaitingWorkflowEdgeContext {
1716 workflow_execution_id: "wf_exec_b".to_string(),
1717 edge_id: "edge_b".to_string(),
1718 from_node_id: "from".to_string(),
1719 to_node_id: "to".to_string(),
1720 event_ref: event.event.clone(),
1721 predicate: Some(WorkflowEdgePredicate {
1722 field: "payload.severity".to_string(),
1723 equals: json!("normal"),
1724 }),
1725 },
1726 WaitingWorkflowEdgeContext {
1727 workflow_execution_id: "wf_exec_a".to_string(),
1728 edge_id: "edge_a".to_string(),
1729 from_node_id: "from".to_string(),
1730 to_node_id: "to".to_string(),
1731 event_ref: event.event.clone(),
1732 predicate: None,
1733 },
1734 ];
1735 let mut consumed = BTreeSet::new();
1736 let first = evaluate_event_driven_edges(
1737 &waiting_edges,
1738 std::slice::from_ref(&event),
1739 &mut consumed,
1740 "trace",
1741 );
1742 assert_eq!(
1743 first.taken_edge_ids,
1744 vec!["edge_a".to_string(), "edge_b".to_string()]
1745 );
1746 assert_eq!(
1747 first
1748 .evidence
1749 .wake_decisions
1750 .iter()
1751 .map(|decision| (&decision.workflow_execution_id, decision.wake_order))
1752 .collect::<Vec<_>>(),
1753 vec![(&"wf_exec_a".to_string(), 1), (&"wf_exec_b".to_string(), 2)]
1754 );
1755
1756 let second = evaluate_event_driven_edges(&waiting_edges, &[event], &mut consumed, "trace");
1757 assert!(second.taken_edge_ids.is_empty());
1758 assert!(
1759 second
1760 .evidence
1761 .match_records
1762 .iter()
1763 .all(|record| record.match_result == EventMatchResult::AlreadyConsumed)
1764 );
1765 }
1766
1767 #[test]
1768 fn event_driven_helpers_reject_non_matching_predicates() {
1769 let waiting_edges = vec![WaitingWorkflowEdgeContext {
1770 workflow_execution_id: "wf_exec_1".to_string(),
1771 edge_id: "edge_predicate".to_string(),
1772 from_node_id: "assess".to_string(),
1773 to_node_id: "validate".to_string(),
1774 event_ref: EventReference {
1775 event_id: "expedition.conditions.summary-assessed".to_string(),
1776 version: "1.0.0".to_string(),
1777 },
1778 predicate: Some(WorkflowEdgePredicate {
1779 field: "payload.severity".to_string(),
1780 equals: json!("high"),
1781 }),
1782 }];
1783 let emitted = vec![EmittedEventRecord {
1784 record_id: "event_record_0".to_string(),
1785 event: EventReference {
1786 event_id: "expedition.conditions.summary-assessed".to_string(),
1787 version: "1.0.0".to_string(),
1788 },
1789 payload: Some(json!({"severity": "normal"})),
1790 source: None,
1791 }];
1792 let outcome =
1793 evaluate_event_driven_edges(&waiting_edges, &emitted, &mut BTreeSet::new(), "trace");
1794 assert!(outcome.taken_edge_ids.is_empty());
1795 assert_eq!(
1796 outcome.evidence.match_records,
1797 vec![EventMatchRecord {
1798 event_id: "expedition.conditions.summary-assessed".to_string(),
1799 event_version: "1.0.0".to_string(),
1800 edge_id: "edge_predicate".to_string(),
1801 match_result: EventMatchResult::NotMatched,
1802 predicate_result: Some(EventPredicateResult::Failed),
1803 rejection_reason: Some(
1804 "event predicate did not match the emitted payload".to_string()
1805 ),
1806 recorded_at: "trace:event:0:match:edge_predicate".to_string(),
1807 subscription_id: None,
1808 cursor: None,
1809 }]
1810 );
1811 }
1812
1813 #[test]
1814 fn event_driven_helpers_record_non_matching_event_identity() {
1815 let waiting_edges = vec![WaitingWorkflowEdgeContext {
1816 workflow_execution_id: "wf_exec_1".to_string(),
1817 edge_id: "edge_identity".to_string(),
1818 from_node_id: "create".to_string(),
1819 to_node_id: "validate".to_string(),
1820 event_ref: EventReference {
1821 event_id: "content.comments.validated".to_string(),
1822 version: "1.0.0".to_string(),
1823 },
1824 predicate: None,
1825 }];
1826 let emitted = vec![EmittedEventRecord {
1827 record_id: "event_record_0".to_string(),
1828 event: EventReference {
1829 event_id: "content.comments.other".to_string(),
1830 version: "1.0.0".to_string(),
1831 },
1832 payload: None,
1833 source: None,
1834 }];
1835 let outcome =
1836 evaluate_event_driven_edges(&waiting_edges, &emitted, &mut BTreeSet::new(), "trace");
1837 assert!(outcome.taken_edge_ids.is_empty());
1838 assert_eq!(
1839 outcome.evidence.match_records,
1840 vec![EventMatchRecord {
1841 event_id: "content.comments.other".to_string(),
1842 event_version: "1.0.0".to_string(),
1843 edge_id: "edge_identity".to_string(),
1844 match_result: EventMatchResult::NotMatched,
1845 predicate_result: None,
1846 rejection_reason: Some(
1847 "event id/version did not match the waiting edge".to_string()
1848 ),
1849 recorded_at: "trace:event:0:match:edge_identity".to_string(),
1850 subscription_id: None,
1851 cursor: None,
1852 }]
1853 );
1854 }
1855
1856 fn event_catalog_broker_fixture(event_type: &str, version: &str) -> Arc<InProcessBroker> {
1857 let catalog = Arc::new(events::EventCatalog::new());
1858 catalog
1859 .register(events::EventCatalogEntry {
1860 event_type: event_type.to_string(),
1861 owner: "content.comments".to_string(),
1862 version: version.to_string(),
1863 lifecycle_status: events::LifecycleStatus::Active,
1864 consumer_count: 0,
1865 })
1866 .unwrap_or_else(|error| unreachable!("{error:?}"));
1867 Arc::new(InProcessBroker::new(catalog).unwrap_or_else(|error| unreachable!("{error:?}")))
1868 }
1869
1870 fn sample_traverse_event(event_type: &str, version: &str) -> events::TraverseEvent {
1871 events::TraverseEvent {
1872 id: "evt-fixture-1".to_string(),
1873 source: "traverse-runtime/content.comments.validate-comment".to_string(),
1874 event_type: event_type.to_string(),
1875 datacontenttype: "application/json".to_string(),
1876 time: "2026-08-06T00:00:00Z".to_string(),
1877 data: json!({"comment_id": "comment-1"}),
1878 owner: "content.comments".to_string(),
1879 version: version.to_string(),
1880 lifecycle_status: events::LifecycleStatus::Active,
1881 deduplication_id: None,
1882 ordering_scope: None,
1883 correlation_id: None,
1884 causation_id: None,
1885 subject_id: None,
1886 actor_id: None,
1887 }
1888 }
1889
1890 #[test]
1894 fn event_driven_edge_advances_from_broker_published_event() {
1895 let broker = event_catalog_broker_fixture("content.comments.validated", "1.0.0");
1896 broker
1897 .publish(sample_traverse_event("content.comments.validated", "1.0.0"))
1898 .unwrap_or_else(|error| unreachable!("{error:?}"));
1899
1900 let runtime = Runtime::new(capability_registry_fixture(), MissingEventWorkflowExecutor)
1901 .with_workflow_registry(workflow_registry_fixture())
1902 .with_security_config(RuntimeSecurityConfig::development())
1903 .with_event_broker(broker);
1904
1905 let outcome = runtime.execute_workflow(valid_workflow_request());
1906 assert_eq!(
1907 outcome.evidence.result.status,
1908 WorkflowTraversalStatus::Completed
1909 );
1910 assert_eq!(outcome.evidence.result.failure_reason, None);
1911
1912 let wake = outcome
1913 .evidence
1914 .event_wake_decisions
1915 .iter()
1916 .find(|decision| decision.edge_id == "validate_to_persist")
1917 .unwrap_or_else(|| unreachable!("expected a wake decision for validate_to_persist"));
1918 assert!(wake.subscription_id.is_some());
1919 assert!(wake.cursor.is_some());
1920
1921 let consumption = outcome
1922 .evidence
1923 .event_consumptions
1924 .iter()
1925 .find(|record| record.edge_id == "validate_to_persist")
1926 .unwrap_or_else(|| unreachable!("expected a consumption record"));
1927 assert!(consumption.subscription_id.is_some());
1928 assert!(consumption.cursor.is_some());
1929 }
1930
1931 #[test]
1935 fn event_driven_edge_same_node_match_has_no_broker_provenance() {
1936 let broker = event_catalog_broker_fixture("content.comments.validated", "1.0.0");
1937 let runtime = Runtime::new(capability_registry_fixture(), WorkflowExecutor)
1938 .with_workflow_registry(workflow_registry_fixture())
1939 .with_security_config(RuntimeSecurityConfig::development())
1940 .with_event_broker(broker);
1941
1942 let outcome = runtime.execute_workflow(valid_workflow_request());
1943 assert_eq!(
1944 outcome.evidence.result.status,
1945 WorkflowTraversalStatus::Completed
1946 );
1947 let wake = outcome
1948 .evidence
1949 .event_wake_decisions
1950 .iter()
1951 .find(|decision| decision.edge_id == "validate_to_persist")
1952 .unwrap_or_else(|| unreachable!("expected a wake decision for validate_to_persist"));
1953 assert_eq!(wake.subscription_id, None);
1954 assert_eq!(wake.cursor, None);
1955 }
1956
1957 #[test]
1961 fn event_driven_edge_fails_with_event_subscription_failed_for_unregistered_type() {
1962 let empty_catalog = Arc::new(events::EventCatalog::new());
1963 let broker = Arc::new(
1964 InProcessBroker::new(empty_catalog).unwrap_or_else(|error| unreachable!("{error:?}")),
1965 );
1966
1967 let runtime = Runtime::new(capability_registry_fixture(), MissingEventWorkflowExecutor)
1968 .with_workflow_registry(workflow_registry_fixture())
1969 .with_security_config(RuntimeSecurityConfig::development())
1970 .with_event_broker(broker);
1971
1972 let outcome = runtime.execute_workflow(valid_workflow_request());
1973 assert_eq!(
1974 outcome.evidence.result.failure_reason,
1975 Some(WorkflowTraversalFailureReason::EventSubscriptionFailed)
1976 );
1977 }
1978
1979 struct AlwaysFailingBroker;
1980
1981 impl EventBroker for AlwaysFailingBroker {
1982 fn publish(&self, _event: events::TraverseEvent) -> Result<(), EventError> {
1983 Err(EventError::JournalWrite(
1984 "simulated broker failure".to_string(),
1985 ))
1986 }
1987
1988 fn subscribe(
1989 &self,
1990 _event_type: &str,
1991 _from_cursor: &str,
1992 ) -> Result<events::Subscription, EventError> {
1993 Err(EventError::JournalRead(
1994 "simulated broker failure".to_string(),
1995 ))
1996 }
1997
1998 fn subscribe_for_subject(
1999 &self,
2000 _event_type: &str,
2001 _from_cursor: &str,
2002 _subject_id: Option<&str>,
2003 ) -> Result<events::Subscription, EventError> {
2004 Err(EventError::JournalRead(
2005 "simulated broker failure".to_string(),
2006 ))
2007 }
2008
2009 fn poll(
2010 &self,
2011 _subscription_id: &str,
2012 _max_events: usize,
2013 ) -> Result<events::SubscriptionPoll, EventError> {
2014 Err(EventError::JournalRead(
2015 "simulated broker failure".to_string(),
2016 ))
2017 }
2018
2019 fn cancel(&self, _subscription_id: &str) -> Result<(), EventError> {
2020 Err(EventError::SubscriptionNotFound("unknown".to_string()))
2021 }
2022 }
2023
2024 #[test]
2029 fn event_driven_edge_fails_with_event_broker_unavailable_when_broker_errors() {
2030 let runtime = Runtime::new(capability_registry_fixture(), MissingEventWorkflowExecutor)
2031 .with_workflow_registry(workflow_registry_fixture())
2032 .with_security_config(RuntimeSecurityConfig::development())
2033 .with_event_broker(Arc::new(AlwaysFailingBroker));
2034
2035 let outcome = runtime.execute_workflow(valid_workflow_request());
2036 assert_eq!(
2037 outcome.evidence.result.failure_reason,
2038 Some(WorkflowTraversalFailureReason::EventBrokerUnavailable)
2039 );
2040 }
2041
2042 #[test]
2047 fn always_failing_broker_fails_every_operation() {
2048 let broker = AlwaysFailingBroker;
2049 assert!(matches!(
2050 broker.publish(sample_traverse_event("content.comments.validated", "1.0.0")),
2051 Err(EventError::JournalWrite(_))
2052 ));
2053 assert!(matches!(
2054 broker.subscribe("content.comments.validated", "0"),
2055 Err(EventError::JournalRead(_))
2056 ));
2057 assert!(matches!(
2058 broker.subscribe_for_subject("content.comments.validated", "0", None),
2059 Err(EventError::JournalRead(_))
2060 ));
2061 assert!(matches!(
2062 broker.poll("sub-1", 1),
2063 Err(EventError::JournalRead(_))
2064 ));
2065 assert!(matches!(
2066 broker.cancel("sub-1"),
2067 Err(EventError::SubscriptionNotFound(_))
2068 ));
2069 }
2070
2071 struct PollFailingBroker(InProcessBroker);
2072
2073 impl EventBroker for PollFailingBroker {
2074 fn publish(&self, event: events::TraverseEvent) -> Result<(), EventError> {
2075 self.0.publish(event)
2076 }
2077
2078 fn subscribe(
2079 &self,
2080 event_type: &str,
2081 from_cursor: &str,
2082 ) -> Result<events::Subscription, EventError> {
2083 self.0.subscribe(event_type, from_cursor)
2084 }
2085
2086 fn subscribe_for_subject(
2087 &self,
2088 event_type: &str,
2089 from_cursor: &str,
2090 subject_id: Option<&str>,
2091 ) -> Result<events::Subscription, EventError> {
2092 self.0
2093 .subscribe_for_subject(event_type, from_cursor, subject_id)
2094 }
2095
2096 fn poll(
2097 &self,
2098 _subscription_id: &str,
2099 _max_events: usize,
2100 ) -> Result<events::SubscriptionPoll, EventError> {
2101 Err(EventError::JournalRead(
2102 "simulated poll failure".to_string(),
2103 ))
2104 }
2105
2106 fn cancel(&self, subscription_id: &str) -> Result<(), EventError> {
2107 self.0.cancel(subscription_id)
2108 }
2109 }
2110
2111 #[test]
2116 fn event_driven_edge_fails_with_event_broker_unavailable_when_poll_errors() {
2117 let catalog = Arc::new(events::EventCatalog::new());
2118 catalog
2119 .register(events::EventCatalogEntry {
2120 event_type: "content.comments.validated".to_string(),
2121 owner: "content.comments".to_string(),
2122 version: "1.0.0".to_string(),
2123 lifecycle_status: events::LifecycleStatus::Active,
2124 consumer_count: 0,
2125 })
2126 .unwrap_or_else(|error| unreachable!("{error:?}"));
2127 let inner = InProcessBroker::new(catalog).unwrap_or_else(|error| unreachable!("{error:?}"));
2128 let broker = Arc::new(PollFailingBroker(inner));
2129
2130 broker
2133 .publish(sample_traverse_event("content.comments.validated", "1.0.0"))
2134 .unwrap_or_else(|error| unreachable!("{error:?}"));
2135 broker
2136 .subscribe_for_subject("content.comments.validated", "0", None)
2137 .unwrap_or_else(|error| unreachable!("{error:?}"));
2138
2139 let runtime = Runtime::new(capability_registry_fixture(), MissingEventWorkflowExecutor)
2140 .with_workflow_registry(workflow_registry_fixture())
2141 .with_security_config(RuntimeSecurityConfig::development())
2142 .with_event_broker(broker);
2143
2144 let outcome = runtime.execute_workflow(valid_workflow_request());
2145 assert_eq!(
2146 outcome.evidence.result.failure_reason,
2147 Some(WorkflowTraversalFailureReason::EventBrokerUnavailable)
2148 );
2149 }
2150
2151 struct CancelFailingBroker(InProcessBroker);
2152
2153 impl EventBroker for CancelFailingBroker {
2154 fn publish(&self, event: events::TraverseEvent) -> Result<(), EventError> {
2155 self.0.publish(event)
2156 }
2157
2158 fn subscribe(
2159 &self,
2160 event_type: &str,
2161 from_cursor: &str,
2162 ) -> Result<events::Subscription, EventError> {
2163 self.0.subscribe(event_type, from_cursor)
2164 }
2165
2166 fn subscribe_for_subject(
2167 &self,
2168 event_type: &str,
2169 from_cursor: &str,
2170 subject_id: Option<&str>,
2171 ) -> Result<events::Subscription, EventError> {
2172 self.0
2173 .subscribe_for_subject(event_type, from_cursor, subject_id)
2174 }
2175
2176 fn poll(
2177 &self,
2178 subscription_id: &str,
2179 max_events: usize,
2180 ) -> Result<events::SubscriptionPoll, EventError> {
2181 self.0.poll(subscription_id, max_events)
2182 }
2183
2184 fn cancel(&self, _subscription_id: &str) -> Result<(), EventError> {
2185 Err(EventError::SubscriptionNotFound(
2186 "simulated cancel failure".to_string(),
2187 ))
2188 }
2189 }
2190
2191 #[test]
2195 fn event_driven_edge_surfaces_warning_when_subscription_cancel_fails() {
2196 let catalog = Arc::new(events::EventCatalog::new());
2197 catalog
2198 .register(events::EventCatalogEntry {
2199 event_type: "content.comments.validated".to_string(),
2200 owner: "content.comments".to_string(),
2201 version: "1.0.0".to_string(),
2202 lifecycle_status: events::LifecycleStatus::Active,
2203 consumer_count: 0,
2204 })
2205 .unwrap_or_else(|error| unreachable!("{error:?}"));
2206 let inner = InProcessBroker::new(catalog).unwrap_or_else(|error| unreachable!("{error:?}"));
2207 inner
2208 .publish(sample_traverse_event("content.comments.validated", "1.0.0"))
2209 .unwrap_or_else(|error| unreachable!("{error:?}"));
2210 let broker = Arc::new(CancelFailingBroker(inner));
2211
2212 broker
2215 .publish(sample_traverse_event("content.comments.validated", "1.0.0"))
2216 .unwrap_or_else(|error| unreachable!("{error:?}"));
2217 broker
2218 .subscribe_for_subject("content.comments.validated", "0", None)
2219 .unwrap_or_else(|error| unreachable!("{error:?}"));
2220
2221 let runtime = Runtime::new(capability_registry_fixture(), MissingEventWorkflowExecutor)
2222 .with_workflow_registry(workflow_registry_fixture())
2223 .with_security_config(RuntimeSecurityConfig::development())
2224 .with_event_broker(broker);
2225
2226 let outcome = runtime.execute_workflow(valid_workflow_request());
2227 assert_eq!(
2228 outcome.evidence.result.status,
2229 WorkflowTraversalStatus::Completed
2230 );
2231 assert!(
2232 outcome
2233 .result
2234 .warnings
2235 .iter()
2236 .any(|warning| warning.code == "event_broker_subscription_cleanup_failed")
2237 );
2238 }
2239
2240 #[test]
2245 fn poll_broker_events_for_waiting_edges_supports_exact_once_across_repeated_polls() {
2246 let broker = event_catalog_broker_fixture("content.comments.validated", "1.0.0");
2247 broker
2248 .publish(sample_traverse_event("content.comments.validated", "1.0.0"))
2249 .unwrap_or_else(|error| unreachable!("{error:?}"));
2250
2251 let waiting_edges = vec![WaitingWorkflowEdgeContext {
2252 workflow_execution_id: "wf_exec_replay".to_string(),
2253 edge_id: "validate_to_persist".to_string(),
2254 from_node_id: "validate_comment".to_string(),
2255 to_node_id: "persist_comment".to_string(),
2256 event_ref: EventReference {
2257 event_id: "content.comments.validated".to_string(),
2258 version: "1.0.0".to_string(),
2259 },
2260 predicate: None,
2261 }];
2262
2263 let mut consumed = BTreeSet::new();
2264
2265 let (first_batch, first_warnings) =
2266 poll_broker_events_for_waiting_edges(broker.as_ref(), &waiting_edges)
2267 .unwrap_or_else(|error| unreachable!("{error:?}"));
2268 assert!(first_warnings.is_empty());
2269 let first = evaluate_event_driven_edges(&waiting_edges, &first_batch, &mut consumed, "t1");
2270 assert_eq!(
2271 first.taken_edge_ids,
2272 vec!["validate_to_persist".to_string()]
2273 );
2274
2275 let (second_batch, second_warnings) =
2279 poll_broker_events_for_waiting_edges(broker.as_ref(), &waiting_edges)
2280 .unwrap_or_else(|error| unreachable!("{error:?}"));
2281 assert!(second_warnings.is_empty());
2282 let second =
2283 evaluate_event_driven_edges(&waiting_edges, &second_batch, &mut consumed, "t2");
2284 assert!(second.taken_edge_ids.is_empty());
2285 assert!(
2286 second
2287 .evidence
2288 .match_records
2289 .iter()
2290 .all(|record| record.match_result == EventMatchResult::AlreadyConsumed)
2291 );
2292 }
2293
2294 #[test]
2298 fn poll_broker_events_for_waiting_edges_cancels_subscriptions_after_draining() {
2299 let catalog = Arc::new(events::EventCatalog::new());
2300 for event_type in [
2301 "content.comments.draft-created",
2302 "content.comments.validated",
2303 ] {
2304 catalog
2305 .register(events::EventCatalogEntry {
2306 event_type: event_type.to_string(),
2307 owner: "content.comments".to_string(),
2308 version: "1.0.0".to_string(),
2309 lifecycle_status: events::LifecycleStatus::Active,
2310 consumer_count: 0,
2311 })
2312 .unwrap_or_else(|error| unreachable!("{error:?}"));
2313 }
2314 let broker =
2315 InProcessBroker::new(catalog).unwrap_or_else(|error| unreachable!("{error:?}"));
2316 broker
2317 .publish(sample_traverse_event("content.comments.validated", "1.0.0"))
2318 .unwrap_or_else(|error| unreachable!("{error:?}"));
2319
2320 let waiting_edges = vec![
2321 WaitingWorkflowEdgeContext {
2322 workflow_execution_id: "wf_exec_multi".to_string(),
2323 edge_id: "edge_a".to_string(),
2324 from_node_id: "a".to_string(),
2325 to_node_id: "b".to_string(),
2326 event_ref: EventReference {
2327 event_id: "content.comments.validated".to_string(),
2328 version: "1.0.0".to_string(),
2329 },
2330 predicate: None,
2331 },
2332 WaitingWorkflowEdgeContext {
2333 workflow_execution_id: "wf_exec_multi".to_string(),
2334 edge_id: "edge_b".to_string(),
2335 from_node_id: "a".to_string(),
2336 to_node_id: "c".to_string(),
2337 event_ref: EventReference {
2338 event_id: "content.comments.draft-created".to_string(),
2339 version: "1.0.0".to_string(),
2340 },
2341 predicate: None,
2342 },
2343 ];
2344
2345 let (records, warnings) = poll_broker_events_for_waiting_edges(&broker, &waiting_edges)
2346 .unwrap_or_else(|error| unreachable!("{error:?}"));
2347 assert!(warnings.is_empty());
2348 assert_eq!(records.len(), 1);
2349 let source = records[0]
2350 .source
2351 .as_ref()
2352 .unwrap_or_else(|| unreachable!("expected broker provenance on the record"));
2353
2354 assert!(matches!(
2357 broker.poll(&source.subscription_id, 1),
2358 Err(EventError::SubscriptionNotFound(id)) if id == source.subscription_id
2359 ));
2360 }
2361
2362 #[test]
2366 fn poll_broker_events_for_waiting_edges_paginates_beyond_one_batch() {
2367 let broker = event_catalog_broker_fixture("content.comments.validated", "1.0.0");
2368 let published_count = EVENT_BROKER_POLL_BATCH + 5;
2369 for index in 0..published_count {
2370 let mut event = sample_traverse_event("content.comments.validated", "1.0.0");
2371 event.id = format!("evt-fixture-{index}");
2372 event.deduplication_id = Some(event.id.clone());
2373 broker
2374 .publish(event)
2375 .unwrap_or_else(|error| unreachable!("{error:?}"));
2376 }
2377
2378 let waiting_edges = vec![WaitingWorkflowEdgeContext {
2379 workflow_execution_id: "wf_exec_paginate".to_string(),
2380 edge_id: "validate_to_persist".to_string(),
2381 from_node_id: "validate_comment".to_string(),
2382 to_node_id: "persist_comment".to_string(),
2383 event_ref: EventReference {
2384 event_id: "content.comments.validated".to_string(),
2385 version: "1.0.0".to_string(),
2386 },
2387 predicate: None,
2388 }];
2389
2390 let (records, warnings) =
2391 poll_broker_events_for_waiting_edges(broker.as_ref(), &waiting_edges)
2392 .unwrap_or_else(|error| unreachable!("{error:?}"));
2393 assert!(warnings.is_empty());
2394 assert_eq!(records.len(), published_count);
2395 }
2396
2397 #[test]
2398 #[allow(clippy::too_many_lines)]
2399 fn workflow_runtime_covers_additional_failure_and_helper_branches() {
2400 let runtime = Runtime::new(capability_registry_fixture(), WorkflowExecutor)
2401 .with_workflow_registry(workflow_registry_fixture())
2402 .with_security_config(RuntimeSecurityConfig::development());
2403
2404 let invalid_input = runtime.execute_workflow(WorkflowExecutionRequest {
2405 input: json!({}),
2406 ..valid_workflow_request()
2407 });
2408 assert_eq!(
2409 invalid_input.evidence.result.failure_reason,
2410 Some(WorkflowTraversalFailureReason::WorkflowInvalid)
2411 );
2412
2413 let invalid_request = runtime.execute_workflow(WorkflowExecutionRequest {
2414 kind: "bad".to_string(),
2415 ..valid_workflow_request()
2416 });
2417 assert_eq!(
2418 invalid_request.evidence.result.failure_reason,
2419 Some(WorkflowTraversalFailureReason::WorkflowInvalid)
2420 );
2421
2422 let missing_node = runtime.traverse_workflow(
2423 &valid_workflow_request(),
2424 &resolved_workflow(WorkflowDefinition {
2425 start_node: "missing".to_string(),
2426 ..workflow_definition_fixture(
2427 Some(EventReference {
2428 event_id: "content.comments.validated".to_string(),
2429 version: "1.0.0".to_string(),
2430 }),
2431 None,
2432 )
2433 }),
2434 );
2435 assert!(missing_node.is_err());
2436
2437 let missing_capability_runtime = Runtime::new(CapabilityRegistry::new(), WorkflowExecutor)
2438 .with_workflow_registry(workflow_registry_fixture());
2439 let missing_capability =
2440 missing_capability_runtime.execute_workflow(valid_workflow_request());
2441 assert_eq!(
2442 missing_capability.evidence.result.failure_reason,
2443 Some(WorkflowTraversalFailureReason::WorkflowInvalid)
2444 );
2445
2446 let strict_runtime =
2447 Runtime::new(strict_input_capability_registry_fixture(), WorkflowExecutor)
2448 .with_workflow_registry(workflow_registry_fixture())
2449 .with_security_config(RuntimeSecurityConfig::development());
2450 let input_mismatch = strict_runtime.traverse_workflow(
2451 &valid_workflow_request(),
2452 &resolved_workflow(WorkflowDefinition {
2453 nodes: vec![WorkflowNode {
2454 input: WorkflowNodeInput {
2455 from_workflow_input: vec!["missing".to_string()],
2456 },
2457 ..workflow_definition_fixture(
2458 Some(EventReference {
2459 event_id: "content.comments.validated".to_string(),
2460 version: "1.0.0".to_string(),
2461 }),
2462 None,
2463 )
2464 .nodes[0]
2465 .clone()
2466 }],
2467 edges: Vec::new(),
2468 start_node: "create_draft".to_string(),
2469 terminal_nodes: vec!["create_draft".to_string()],
2470 ..workflow_definition_fixture(
2471 Some(EventReference {
2472 event_id: "content.comments.validated".to_string(),
2473 version: "1.0.0".to_string(),
2474 }),
2475 None,
2476 )
2477 }),
2478 );
2479 assert!(input_mismatch.is_err());
2480
2481 let bad_output_runtime =
2482 Runtime::new(capability_registry_fixture(), BadOutputWorkflowExecutor)
2483 .with_workflow_registry(workflow_registry_fixture())
2484 .with_security_config(RuntimeSecurityConfig::development());
2485 let bad_output = bad_output_runtime.execute_workflow(valid_workflow_request());
2486 assert_eq!(
2487 bad_output.evidence.result.failure_reason,
2488 Some(WorkflowTraversalFailureReason::StepExecutionFailed)
2489 );
2490
2491 let undeclared_event_runtime = Runtime::new(
2496 capability_registry_fixture(),
2497 UndeclaredEventWorkflowExecutor,
2498 )
2499 .with_workflow_registry(workflow_registry_fixture())
2500 .with_security_config(RuntimeSecurityConfig::development());
2501 let undeclared_event = undeclared_event_runtime.execute_workflow(valid_workflow_request());
2502 assert_eq!(
2503 undeclared_event.evidence.result.failure_reason,
2504 Some(WorkflowTraversalFailureReason::StepExecutionFailed)
2505 );
2506
2507 let direct_success = runtime.traverse_workflow(
2508 &valid_workflow_request(),
2509 &resolved_workflow(workflow_definition_fixture(
2510 Some(EventReference {
2511 event_id: "content.comments.validated".to_string(),
2512 version: "1.0.0".to_string(),
2513 }),
2514 Some(WorkflowEdge {
2515 edge_id: "direct".to_string(),
2516 from: "create_draft".to_string(),
2517 to: "validate_comment".to_string(),
2518 trigger: WorkflowEdgeTrigger::Direct,
2519 event: None,
2520 predicate: None,
2521 }),
2522 )),
2523 );
2524 assert!(direct_success.is_ok());
2525
2526 let ambiguous_direct = runtime.traverse_workflow(
2527 &valid_workflow_request(),
2528 &resolved_workflow(WorkflowDefinition {
2529 edges: vec![
2530 WorkflowEdge {
2531 edge_id: "direct-1".to_string(),
2532 from: "create_draft".to_string(),
2533 to: "validate_comment".to_string(),
2534 trigger: WorkflowEdgeTrigger::Direct,
2535 event: None,
2536 predicate: None,
2537 },
2538 WorkflowEdge {
2539 edge_id: "direct-2".to_string(),
2540 from: "create_draft".to_string(),
2541 to: "persist_comment".to_string(),
2542 trigger: WorkflowEdgeTrigger::Direct,
2543 event: None,
2544 predicate: None,
2545 },
2546 ],
2547 ..workflow_definition_fixture(
2548 Some(EventReference {
2549 event_id: "content.comments.validated".to_string(),
2550 version: "1.0.0".to_string(),
2551 }),
2552 None,
2553 )
2554 }),
2555 );
2556 assert!(ambiguous_direct.is_err());
2557
2558 let ambiguous_event = runtime.traverse_workflow(
2559 &valid_workflow_request(),
2560 &resolved_workflow(WorkflowDefinition {
2561 edges: vec![
2562 WorkflowEdge {
2563 edge_id: "draft_to_validate".to_string(),
2564 from: "create_draft".to_string(),
2565 to: "validate_comment".to_string(),
2566 trigger: WorkflowEdgeTrigger::Event,
2567 event: Some(EventReference {
2568 event_id: "content.comments.draft-created".to_string(),
2569 version: "1.0.0".to_string(),
2570 }),
2571 predicate: None,
2572 },
2573 WorkflowEdge {
2574 edge_id: "draft_to_persist".to_string(),
2575 from: "create_draft".to_string(),
2576 to: "persist_comment".to_string(),
2577 trigger: WorkflowEdgeTrigger::Event,
2578 event: Some(EventReference {
2579 event_id: "content.comments.draft-created".to_string(),
2580 version: "1.0.0".to_string(),
2581 }),
2582 predicate: None,
2583 },
2584 ],
2585 ..workflow_definition_fixture(
2586 Some(EventReference {
2587 event_id: "content.comments.validated".to_string(),
2588 version: "1.0.0".to_string(),
2589 }),
2590 None,
2591 )
2592 }),
2593 );
2594 assert!(ambiguous_event.is_err());
2595
2596 let terminal_miss = runtime.traverse_workflow(
2597 &valid_workflow_request(),
2598 &resolved_workflow(WorkflowDefinition {
2599 edges: Vec::new(),
2600 terminal_nodes: vec!["persist_comment".to_string()],
2601 ..workflow_definition_fixture(
2602 Some(EventReference {
2603 event_id: "content.comments.validated".to_string(),
2604 version: "1.0.0".to_string(),
2605 }),
2606 None,
2607 )
2608 }),
2609 );
2610 assert!(terminal_miss.is_err());
2611
2612 let invalid_final_output = runtime.traverse_workflow(
2613 &valid_workflow_request(),
2614 &resolved_workflow(WorkflowDefinition {
2615 outputs: SchemaContainer {
2616 schema: json!({
2617 "type": "object",
2618 "properties": { "missing": { "type": "string" } },
2619 "required": ["missing"],
2620 "additionalProperties": true
2621 }),
2622 },
2623 ..workflow_definition_fixture(
2624 Some(EventReference {
2625 event_id: "content.comments.validated".to_string(),
2626 version: "1.0.0".to_string(),
2627 }),
2628 None,
2629 )
2630 }),
2631 );
2632 assert!(invalid_final_output.is_err());
2633
2634 let selection = SelectionRecord {
2635 status: crate::SelectionStatus::Selected,
2636 selected_capability_id: Some("content.comments.publish-comment".to_string()),
2637 selected_capability_version: Some("1.0.0".to_string()),
2638 failure_reason: None,
2639 remaining_candidates: Vec::new(),
2640 };
2641 let mut selected = runtime
2642 .registry
2643 .find_exact(
2644 LookupScope::PublicOnly,
2645 "content.comments.create-comment-draft",
2646 "1.0.0",
2647 )
2648 .unwrap_or_else(|| unreachable!("fixture capability missing"));
2649 selected.record.implementation_kind = ImplementationKind::Workflow;
2650 let (attempt, mut emitter) = super::super::begin_attempt(
2651 RuntimeRequest {
2652 kind: "runtime_request".to_string(),
2653 schema_version: "1.0.0".to_string(),
2654 request_id: "workflow-capability".to_string(),
2655 intent: RuntimeIntent {
2656 capability_id: Some("content.comments.publish-comment".to_string()),
2657 capability_version: Some("1.0.0".to_string()),
2658 version_range: None,
2659 intent_key: None,
2660 },
2661 input: json!({"comment_text": "hello"}),
2662 lookup: RuntimeLookup {
2663 scope: RuntimeLookupScope::PublicOnly,
2664 allow_ambiguity: false,
2665 },
2666 context: RuntimeContext {
2667 requested_target: crate::PlacementTarget::Local,
2668 correlation_id: None,
2669 caller: None,
2670 traceparent: None,
2671 tracestate: None,
2672 metadata: None,
2673 identity: None,
2674 },
2675 governing_spec: "006-runtime-request-execution".to_string(),
2676 },
2677 crate::RuntimeObservabilityConfig::default(),
2678 );
2679 emitter.push(
2680 crate::RuntimeState::Discovering,
2681 crate::RuntimeTransitionReasonCode::RequestStarted,
2682 json!({"lookup_scope": RuntimeLookupScope::PublicOnly}),
2683 );
2684 emitter.push(
2685 crate::RuntimeState::EvaluatingConstraints,
2686 crate::RuntimeTransitionReasonCode::CandidatesCollected,
2687 json!({"candidate_count": 1}),
2688 );
2689 emitter.push(
2690 crate::RuntimeState::Selecting,
2691 crate::RuntimeTransitionReasonCode::ConstraintsEvaluated,
2692 json!({"eligible_candidates": 1, "rejected_candidates": 0}),
2693 );
2694 let started_execution = crate::start_selected_execution(
2695 &mut emitter,
2696 &selected,
2697 crate::resolve_placement(crate::PlacementTarget::Local)
2698 .unwrap_or_else(|_| unreachable!("local placement should resolve")),
2699 None,
2700 );
2701 let outcome = runtime.execute_workflow_capability(
2702 crate::ExecutionContext {
2703 attempt,
2704 emitter,
2705 candidate_collection: CandidateCollectionRecord {
2706 lookup_scope: RuntimeLookupScope::PublicOnly,
2707 candidates: Vec::new(),
2708 rejected_candidates: Vec::new(),
2709 },
2710 selection,
2711 },
2712 &selected,
2713 started_execution,
2714 );
2715 assert_eq!(outcome.result.status, RuntimeResultStatus::Error);
2716
2717 let mut selected = runtime
2718 .registry
2719 .find_exact(
2720 LookupScope::PublicOnly,
2721 "content.comments.create-comment-draft",
2722 "1.0.0",
2723 )
2724 .unwrap_or_else(|| unreachable!("fixture capability missing"));
2725 selected.record.scope = RegistryScope::Private;
2726 selected.record.implementation_kind = ImplementationKind::Workflow;
2727 selected.artifact.workflow_ref = Some(traverse_registry::WorkflowReference {
2728 workflow_id: "content.comments.publish-comment".to_string(),
2729 workflow_version: "1.0.0".to_string(),
2730 });
2731 let (attempt, mut emitter) = super::super::begin_attempt(
2732 RuntimeRequest {
2733 request_id: "workflow-private".to_string(),
2734 ..valid_runtime_request()
2735 },
2736 crate::RuntimeObservabilityConfig::default(),
2737 );
2738 emitter.push(
2739 crate::RuntimeState::Discovering,
2740 crate::RuntimeTransitionReasonCode::RequestStarted,
2741 json!({"lookup_scope": RuntimeLookupScope::PreferPrivate}),
2742 );
2743 emitter.push(
2744 crate::RuntimeState::EvaluatingConstraints,
2745 crate::RuntimeTransitionReasonCode::CandidatesCollected,
2746 json!({"candidate_count": 1}),
2747 );
2748 emitter.push(
2749 crate::RuntimeState::Selecting,
2750 crate::RuntimeTransitionReasonCode::ConstraintsEvaluated,
2751 json!({"eligible_candidates": 1, "rejected_candidates": 0}),
2752 );
2753 let started_execution = crate::start_selected_execution(
2754 &mut emitter,
2755 &selected,
2756 crate::resolve_placement(crate::PlacementTarget::Local)
2757 .unwrap_or_else(|_| unreachable!("local placement should resolve")),
2758 None,
2759 );
2760 let failing_runtime = Runtime::new(capability_registry_fixture(), FailingWorkflowExecutor)
2761 .with_workflow_registry(workflow_registry_fixture())
2762 .with_security_config(RuntimeSecurityConfig::development());
2763 let outcome = failing_runtime.execute_workflow_capability(
2764 crate::ExecutionContext {
2765 attempt,
2766 emitter,
2767 candidate_collection: CandidateCollectionRecord {
2768 lookup_scope: RuntimeLookupScope::PreferPrivate,
2769 candidates: Vec::new(),
2770 rejected_candidates: Vec::new(),
2771 },
2772 selection: SelectionRecord {
2773 status: crate::SelectionStatus::Selected,
2774 selected_capability_id: Some("content.comments.publish-comment".to_string()),
2775 selected_capability_version: Some("1.0.0".to_string()),
2776 failure_reason: None,
2777 remaining_candidates: Vec::new(),
2778 },
2779 },
2780 &selected,
2781 started_execution,
2782 );
2783 assert_eq!(outcome.result.status, RuntimeResultStatus::Error);
2784
2785 let mut unknown = runtime
2786 .registry
2787 .find_exact(
2788 LookupScope::PublicOnly,
2789 "content.comments.create-comment-draft",
2790 "1.0.0",
2791 )
2792 .unwrap_or_else(|| unreachable!("fixture capability missing"));
2793 unknown.record.id = "unknown".to_string();
2794 let _ = WorkflowExecutor.execute(&unknown, &json!({}));
2795 let _ = MissingEventWorkflowExecutor.execute(&unknown, &json!({}));
2796 let _ = BadOutputWorkflowExecutor.execute(&unknown, &json!({}));
2797 unknown.record.id = "content.comments.persist-comment".to_string();
2798 let _ = MissingEventWorkflowExecutor.execute(&unknown, &json!({}));
2799 }
2800
2801 #[test]
2802 fn pipeline_workflow_merges_namespaced_step_outputs_deterministically() {
2803 let registry = pipeline_capability_registry();
2804 let mut workflows = WorkflowRegistry::new();
2805 register_workflow_ok(&mut workflows, ®istry, pipeline_workflow_registration());
2806 let runtime = Runtime::new(registry, PipelineExecutor)
2807 .with_workflow_registry(workflows)
2808 .with_security_config(RuntimeSecurityConfig::development());
2809
2810 let first = runtime.execute_workflow(pipeline_workflow_request());
2811 let second = runtime.execute_workflow(pipeline_workflow_request());
2812
2813 assert_eq!(first.result.status, WorkflowTraversalStatus::Completed);
2814 assert_eq!(
2815 first.result.output,
2816 Some(json!({
2817 "validate": {"valid": true, "issues": []},
2818 "process": {
2819 "title": "Hello world",
2820 "tags": ["hello", "world"],
2821 "noteType": "fleeting",
2822 "suggestedNextAction": "archive",
2823 "status": "complete"
2824 },
2825 "summarize": {"summary": "Hello world (fleeting)", "wordCount": 3}
2826 }))
2827 );
2828 assert_eq!(first.result, second.result);
2829 assert_eq!(first.evidence.visited_nodes, second.evidence.visited_nodes);
2830
2831 let steps = &first.evidence.visited_nodes;
2832 assert_eq!(steps.len(), 3);
2833 assert_eq!(steps[0].step_index, 0);
2834 assert_eq!(steps[0].capability_id, "content.comments.pipeline-validate");
2835 assert_eq!(steps[1].step_index, 1);
2836 assert_eq!(steps[1].capability_id, "content.comments.pipeline-process");
2837 assert_eq!(steps[2].step_index, 2);
2838 assert_eq!(
2839 steps[2].capability_id,
2840 "content.comments.pipeline-summarize"
2841 );
2842 assert!(
2843 steps
2844 .iter()
2845 .all(|step| step.status == WorkflowTraversalStepStatus::Completed)
2846 );
2847 }
2848
2849 #[test]
2850 fn pipeline_workflow_stops_on_failed_step_with_failed_step_id_in_trace() {
2851 let registry = pipeline_capability_registry();
2852 let mut workflows = WorkflowRegistry::new();
2853 register_workflow_ok(&mut workflows, ®istry, pipeline_workflow_registration());
2854 let runtime = Runtime::new(registry, FailingPipelineExecutor)
2855 .with_workflow_registry(workflows)
2856 .with_security_config(RuntimeSecurityConfig::development());
2857
2858 let outcome = runtime.execute_workflow(pipeline_workflow_request());
2859
2860 assert_eq!(outcome.result.status, WorkflowTraversalStatus::Error);
2861 assert_eq!(
2862 outcome.evidence.result.failure_reason,
2863 Some(WorkflowTraversalFailureReason::StepExecutionFailed)
2864 );
2865 let steps = &outcome.evidence.visited_nodes;
2866 assert_eq!(steps.len(), 2);
2867 assert_eq!(steps[0].status, WorkflowTraversalStepStatus::Completed);
2868 assert_eq!(steps[1].node_id, "process_note");
2869 assert_eq!(steps[1].status, WorkflowTraversalStepStatus::Failed);
2870 }
2871
2872 #[test]
2873 fn workflow_step_rejects_unsigned_artifact_under_default_security_config() {
2874 let runtime = Runtime::new(capability_registry_fixture(), WorkflowExecutor)
2878 .with_workflow_registry(workflow_registry_fixture());
2879
2880 let outcome = runtime.execute_workflow(valid_workflow_request());
2881
2882 assert_eq!(outcome.result.status, WorkflowTraversalStatus::Error);
2883 assert_eq!(
2884 outcome.evidence.result.failure_reason,
2885 Some(WorkflowTraversalFailureReason::StepExecutionFailed)
2886 );
2887 let error = outcome.result.error;
2888 assert_eq!(
2889 error.as_ref().map(|error| error.code),
2890 Some(RuntimeErrorCode::ContractViolation)
2891 );
2892 assert_eq!(
2893 error
2894 .as_ref()
2895 .and_then(|error| error.details.get("code"))
2896 .and_then(Value::as_str),
2897 Some("missing_signature")
2898 );
2899 assert_eq!(
2900 error
2901 .as_ref()
2902 .and_then(|error| error.details.get("node_id"))
2903 .and_then(Value::as_str),
2904 Some("create_draft")
2905 );
2906 let steps = &outcome.evidence.visited_nodes;
2907 assert_eq!(steps.len(), 1);
2908 assert_eq!(steps[0].node_id, "create_draft");
2909 assert_eq!(steps[0].status, WorkflowTraversalStepStatus::Failed);
2910 assert!(outcome.result.warnings.is_empty());
2911 }
2912
2913 #[test]
2914 fn workflow_step_unsigned_artifact_warns_and_executes_in_development_mode() {
2915 let runtime = Runtime::new(capability_registry_fixture(), WorkflowExecutor)
2916 .with_workflow_registry(workflow_registry_fixture())
2917 .with_security_config(RuntimeSecurityConfig::development());
2918
2919 let outcome = runtime.execute_workflow(valid_workflow_request());
2920
2921 assert_eq!(outcome.result.status, WorkflowTraversalStatus::Completed);
2922 assert_eq!(outcome.result.warnings.len(), 3);
2923 assert!(
2924 outcome
2925 .result
2926 .warnings
2927 .iter()
2928 .all(|warning| warning.code == "unsigned_local_dev_artifact")
2929 );
2930 }
2931
2932 #[test]
2933 fn workflow_step_fails_when_signed_artifact_bytes_cannot_be_loaded() {
2934 let runtime = Runtime::new(
2935 signed_missing_binary_capability_registry_fixture(),
2936 WorkflowExecutor,
2937 )
2938 .with_workflow_registry(workflow_registry_fixture());
2939
2940 let outcome = runtime.execute_workflow(valid_workflow_request());
2941
2942 assert_eq!(outcome.result.status, WorkflowTraversalStatus::Error);
2943 assert_eq!(
2944 outcome.evidence.result.failure_reason,
2945 Some(WorkflowTraversalFailureReason::StepExecutionFailed)
2946 );
2947 let error = outcome.result.error;
2948 assert_eq!(
2949 error.as_ref().map(|error| error.code),
2950 Some(RuntimeErrorCode::ArtifactMissing)
2951 );
2952 assert_eq!(
2953 error
2954 .as_ref()
2955 .and_then(|error| error.details.get("code"))
2956 .and_then(Value::as_str),
2957 Some("artifact_load_failed")
2958 );
2959 let steps = &outcome.evidence.visited_nodes;
2960 assert_eq!(steps.len(), 1);
2961 assert_eq!(steps[0].status, WorkflowTraversalStepStatus::Failed);
2962 }
2963
2964 fn pipeline_capability_registry() -> CapabilityRegistry {
2965 let mut registry = CapabilityRegistry::new();
2966 for id in [
2967 "content.comments.pipeline-validate",
2968 "content.comments.pipeline-process",
2969 "content.comments.pipeline-summarize",
2970 ] {
2971 register_capability_ok(
2972 &mut registry,
2973 CapabilityRegistration {
2974 scope: RegistryScope::Public,
2975 contract: capability_contract(
2976 id,
2977 Vec::new(),
2978 json!({"type": "object", "additionalProperties": true}),
2979 json!({"type": "object", "additionalProperties": true}),
2980 ),
2981 contract_path: format!("registry/{id}.json"),
2982 artifact: CapabilityArtifactRecord {
2983 artifact_ref: format!("artifact-{id}"),
2984 implementation_kind: ImplementationKind::Executable,
2985 source: SourceReference {
2986 kind: SourceKind::Git,
2987 location: format!("https://example.com/{id}.git"),
2988 },
2989 binary: Some(BinaryReference {
2990 format: BinaryFormat::Wasm,
2991 location: format!("{id}.wasm"),
2992 signature: None,
2993 }),
2994 workflow_ref: None,
2995 digests: ArtifactDigests {
2996 source_digest: "source".to_string(),
2997 binary_digest: Some("binary".to_string()),
2998 },
2999 provenance: RegistryProvenance {
3000 source: "fixtures".to_string(),
3001 author: "Enrico".to_string(),
3002 created_at: "2026-03-27T00:00:00Z".to_string(),
3003 },
3004 },
3005 registered_at: "2026-03-27T00:00:00Z".to_string(),
3006 tags: vec!["pipeline".to_string()],
3007 composability: ComposabilityMetadata {
3008 kind: CompositionKind::Atomic,
3009 patterns: vec![CompositionPattern::Sequential],
3010 provides: vec!["pipeline-step".to_string()],
3011 requires: Vec::new(),
3012 },
3013 governing_spec: "005-capability-registry".to_string(),
3014 validator_version: "validator".to_string(),
3015 },
3016 );
3017 }
3018 registry
3019 }
3020
3021 #[allow(clippy::too_many_lines)]
3022 fn pipeline_workflow_registration() -> WorkflowRegistration {
3023 WorkflowRegistration {
3024 scope: RegistryScope::Public,
3025 definition: WorkflowDefinition {
3026 kind: "workflow_definition".to_string(),
3027 schema_version: "1.0.0".to_string(),
3028 id: "content.comments.pipeline".to_string(),
3029 name: "pipeline".to_string(),
3030 version: "1.0.0".to_string(),
3031 lifecycle: Lifecycle::Active,
3032 owner: Owner {
3033 team: "traverse-core".to_string(),
3034 contact: "test@example.com".to_string(),
3035 },
3036 summary: "Deterministic three-step pipeline fixture.".to_string(),
3037 inputs: SchemaContainer {
3038 schema: json!({
3039 "type": "object",
3040 "required": ["note"],
3041 "properties": {"note": {"type": "string"}},
3042 "additionalProperties": false
3043 }),
3044 },
3045 outputs: SchemaContainer {
3046 schema: json!({
3047 "type": "object",
3048 "required": ["validate", "process", "summarize"],
3049 "properties": {
3050 "validate": {"type": "object"},
3051 "process": {"type": "object"},
3052 "summarize": {"type": "object"}
3053 },
3054 "additionalProperties": false
3055 }),
3056 },
3057 nodes: vec![
3058 WorkflowNode {
3059 node_id: "validate_note".to_string(),
3060 capability_id: "content.comments.pipeline-validate".to_string(),
3061 capability_version: "1.0.0".to_string(),
3062 input: WorkflowNodeInput {
3063 from_workflow_input: vec!["note".to_string()],
3064 },
3065 output: WorkflowNodeOutput {
3066 to_workflow_state: Vec::new(),
3067 publish_to_state_as: Some("validate".to_string()),
3068 },
3069 },
3070 WorkflowNode {
3071 node_id: "process_note".to_string(),
3072 capability_id: "content.comments.pipeline-process".to_string(),
3073 capability_version: "1.0.0".to_string(),
3074 input: WorkflowNodeInput {
3075 from_workflow_input: vec!["note".to_string()],
3076 },
3077 output: WorkflowNodeOutput {
3078 to_workflow_state: vec![
3079 "title".to_string(),
3080 "tags".to_string(),
3081 "noteType".to_string(),
3082 "suggestedNextAction".to_string(),
3083 "status".to_string(),
3084 ],
3085 publish_to_state_as: Some("process".to_string()),
3086 },
3087 },
3088 WorkflowNode {
3089 node_id: "summarize_note".to_string(),
3090 capability_id: "content.comments.pipeline-summarize".to_string(),
3091 capability_version: "1.0.0".to_string(),
3092 input: WorkflowNodeInput {
3093 from_workflow_input: vec![
3094 "title".to_string(),
3095 "tags".to_string(),
3096 "noteType".to_string(),
3097 "suggestedNextAction".to_string(),
3098 "status".to_string(),
3099 ],
3100 },
3101 output: WorkflowNodeOutput {
3102 to_workflow_state: Vec::new(),
3103 publish_to_state_as: Some("summarize".to_string()),
3104 },
3105 },
3106 ],
3107 edges: vec![
3108 WorkflowEdge {
3109 edge_id: "validate_to_process".to_string(),
3110 from: "validate_note".to_string(),
3111 to: "process_note".to_string(),
3112 trigger: WorkflowEdgeTrigger::Direct,
3113 event: None,
3114 predicate: None,
3115 },
3116 WorkflowEdge {
3117 edge_id: "process_to_summarize".to_string(),
3118 from: "process_note".to_string(),
3119 to: "summarize_note".to_string(),
3120 trigger: WorkflowEdgeTrigger::Direct,
3121 event: None,
3122 predicate: None,
3123 },
3124 ],
3125 start_node: "validate_note".to_string(),
3126 terminal_nodes: vec!["summarize_note".to_string()],
3127 output_projection: vec![
3128 "validate".to_string(),
3129 "process".to_string(),
3130 "summarize".to_string(),
3131 ],
3132 tags: vec!["pipeline".to_string()],
3133 governing_spec: "007-workflow-registry-traversal".to_string(),
3134 },
3135 workflow_path: "workflows/content.comments.pipeline/workflow.json".to_string(),
3136 registered_at: "2026-07-08T00:00:00Z".to_string(),
3137 validator_version: "validator".to_string(),
3138 }
3139 }
3140
3141 fn pipeline_workflow_request() -> WorkflowExecutionRequest {
3142 WorkflowExecutionRequest {
3143 kind: "workflow_execution_request".to_string(),
3144 schema_version: "1.0.0".to_string(),
3145 request_id: "pipeline-request".to_string(),
3146 workflow_id: "content.comments.pipeline".to_string(),
3147 workflow_version: "1.0.0".to_string(),
3148 scope: WorkflowLookupScope::PublicOnly,
3149 input: json!({"note": "Hello world"}),
3150 governing_spec: "007-workflow-registry-traversal".to_string(),
3151 }
3152 }
3153
3154 struct PipelineExecutor;
3155
3156 impl LocalExecutor for PipelineExecutor {
3157 fn execute(
3158 &self,
3159 capability: &ResolvedCapability,
3160 _input: &Value,
3161 ) -> Result<LocalExecutionOutput, LocalExecutionFailure> {
3162 let value = match capability.record.id.as_str() {
3163 "content.comments.pipeline-validate" => json!({"valid": true, "issues": []}),
3164 "content.comments.pipeline-process" => json!({
3165 "title": "Hello world",
3166 "tags": ["hello", "world"],
3167 "noteType": "fleeting",
3168 "suggestedNextAction": "archive",
3169 "status": "complete"
3170 }),
3171 _ => json!({"summary": "Hello world (fleeting)", "wordCount": 3}),
3172 };
3173 Ok(LocalExecutionOutput {
3174 value,
3175 emitted_events: Vec::new(),
3176 })
3177 }
3178 }
3179
3180 struct FailingPipelineExecutor;
3181
3182 impl LocalExecutor for FailingPipelineExecutor {
3183 fn execute(
3184 &self,
3185 capability: &ResolvedCapability,
3186 _input: &Value,
3187 ) -> Result<LocalExecutionOutput, LocalExecutionFailure> {
3188 match capability.record.id.as_str() {
3189 "content.comments.pipeline-validate" => Ok(LocalExecutionOutput {
3190 value: json!({"valid": true, "issues": []}),
3191 emitted_events: Vec::new(),
3192 }),
3193 other => Err(LocalExecutionFailure {
3194 code: LocalExecutionFailureCode::ExecutionFailed,
3195 message: format!("step failed: {other}"),
3196 }),
3197 }
3198 }
3199 }
3200
3201 fn capability_registry_fixture() -> CapabilityRegistry {
3202 build_capability_registry(false, None)
3203 }
3204
3205 fn signed_missing_binary_capability_registry_fixture() -> CapabilityRegistry {
3206 build_capability_registry(
3207 false,
3208 Some(ArtifactSignature {
3209 scheme: ArtifactSignatureScheme::Ed25519,
3210 public_key_hex: Some("00".repeat(32)),
3211 signature_hex: Some("00".repeat(64)),
3212 sigstore_bundle_ref: None,
3213 }),
3214 )
3215 }
3216
3217 #[allow(clippy::too_many_lines, clippy::needless_pass_by_value)]
3218 fn build_capability_registry(
3219 strict_inputs: bool,
3220 signature: Option<ArtifactSignature>,
3221 ) -> CapabilityRegistry {
3222 let mut registry = CapabilityRegistry::new();
3223 for (id, emits, output, required_key) in [
3224 (
3225 "content.comments.create-comment-draft",
3226 vec![EventReference {
3227 event_id: "content.comments.draft-created".to_string(),
3228 version: "1.0.0".to_string(),
3229 }],
3230 json!({
3231 "type": "object",
3232 "properties": {
3233 "draft_id": { "type": "string" },
3234 "emitted_events": { "type": "array" }
3235 },
3236 "required": ["draft_id"],
3237 "additionalProperties": true
3238 }),
3239 "comment_text",
3240 ),
3241 (
3242 "content.comments.validate-comment",
3243 vec![EventReference {
3244 event_id: "content.comments.validated".to_string(),
3245 version: "1.0.0".to_string(),
3246 }],
3247 json!({
3248 "type": "object",
3249 "properties": {
3250 "draft_id": { "type": "string" },
3251 "emitted_events": { "type": "array" }
3252 },
3253 "required": ["draft_id"],
3254 "additionalProperties": true
3255 }),
3256 "draft_id",
3257 ),
3258 (
3259 "content.comments.persist-comment",
3260 vec![],
3261 json!({
3262 "type": "object",
3263 "properties": { "comment_id": { "type": "string" } },
3264 "required": ["comment_id"],
3265 "additionalProperties": true
3266 }),
3267 "draft_id",
3268 ),
3269 ] {
3270 register_capability_ok(
3271 &mut registry,
3272 CapabilityRegistration {
3273 scope: RegistryScope::Public,
3274 contract: capability_contract(
3275 id,
3276 emits,
3277 json!({
3278 "type": "object",
3279 "properties": {
3280 "comment_text": { "type": "string" },
3281 "draft_id": { "type": "string" }
3282 },
3283 "required": if strict_inputs {
3284 vec![required_key]
3285 } else {
3286 Vec::<&str>::new()
3287 },
3288 "additionalProperties": true
3289 }),
3290 output,
3291 ),
3292 contract_path: format!("registry/{id}.json"),
3293 artifact: CapabilityArtifactRecord {
3294 artifact_ref: format!("artifact-{id}"),
3295 implementation_kind: ImplementationKind::Executable,
3296 source: SourceReference {
3297 kind: SourceKind::Git,
3298 location: format!("https://example.com/{id}.git"),
3299 },
3300 binary: Some(BinaryReference {
3301 format: BinaryFormat::Wasm,
3302 location: format!("{id}.wasm"),
3303 signature: signature.clone(),
3304 }),
3305 workflow_ref: None,
3306 digests: ArtifactDigests {
3307 source_digest: "source".to_string(),
3308 binary_digest: Some("binary".to_string()),
3309 },
3310 provenance: RegistryProvenance {
3311 source: "fixtures".to_string(),
3312 author: "Enrico".to_string(),
3313 created_at: "2026-03-27T00:00:00Z".to_string(),
3314 },
3315 },
3316 registered_at: "2026-03-27T00:00:00Z".to_string(),
3317 tags: vec!["comments".to_string()],
3318 composability: ComposabilityMetadata {
3319 kind: CompositionKind::Atomic,
3320 patterns: vec![CompositionPattern::Sequential],
3321 provides: vec!["comment".to_string()],
3322 requires: Vec::new(),
3323 },
3324 governing_spec: "005-capability-registry".to_string(),
3325 validator_version: "validator".to_string(),
3326 },
3327 );
3328 }
3329 registry
3330 }
3331
3332 fn strict_input_capability_registry_fixture() -> CapabilityRegistry {
3333 build_capability_registry(true, None)
3334 }
3335
3336 fn workflow_registry_fixture() -> WorkflowRegistry {
3337 let registry = capability_registry_fixture();
3338 let mut workflows = WorkflowRegistry::new();
3339 register_workflow_ok(
3340 &mut workflows,
3341 ®istry,
3342 WorkflowRegistration {
3343 scope: RegistryScope::Public,
3344 definition: workflow_definition_fixture(
3345 Some(EventReference {
3346 event_id: "content.comments.validated".to_string(),
3347 version: "1.0.0".to_string(),
3348 }),
3349 None,
3350 ),
3351 workflow_path: "workflows/publish-comment.json".to_string(),
3352 registered_at: "2026-03-27T00:00:00Z".to_string(),
3353 validator_version: "workflow-validator".to_string(),
3354 },
3355 );
3356 workflows
3357 }
3358
3359 fn workflow_definition_fixture(
3360 second_event: Option<EventReference>,
3361 direct_edge: Option<WorkflowEdge>,
3362 ) -> WorkflowDefinition {
3363 let mut edges = vec![
3364 WorkflowEdge {
3365 edge_id: "draft_to_validate".to_string(),
3366 from: "create_draft".to_string(),
3367 to: "validate_comment".to_string(),
3368 trigger: WorkflowEdgeTrigger::Event,
3369 event: Some(EventReference {
3370 event_id: "content.comments.draft-created".to_string(),
3371 version: "1.0.0".to_string(),
3372 }),
3373 predicate: None,
3374 },
3375 WorkflowEdge {
3376 edge_id: "validate_to_persist".to_string(),
3377 from: "validate_comment".to_string(),
3378 to: "persist_comment".to_string(),
3379 trigger: WorkflowEdgeTrigger::Event,
3380 event: second_event,
3381 predicate: None,
3382 },
3383 ];
3384 if let Some(edge) = direct_edge {
3385 edges.push(edge);
3386 }
3387 WorkflowDefinition {
3388 kind: "workflow_definition".to_string(),
3389 schema_version: "1.0.0".to_string(),
3390 id: "content.comments.publish-comment".to_string(),
3391 name: "publish-comment".to_string(),
3392 version: "1.0.0".to_string(),
3393 lifecycle: Lifecycle::Active,
3394 owner: Owner {
3395 team: "comments".to_string(),
3396 contact: "comments@example.com".to_string(),
3397 },
3398 summary: "Publish a comment deterministically.".to_string(),
3399 inputs: SchemaContainer {
3400 schema: json!({
3401 "type": "object",
3402 "properties": { "comment_text": { "type": "string" } },
3403 "required": ["comment_text"],
3404 "additionalProperties": true
3405 }),
3406 },
3407 outputs: SchemaContainer {
3408 schema: json!({
3409 "type": "object",
3410 "properties": { "comment_id": { "type": "string" } },
3411 "required": ["comment_id"],
3412 "additionalProperties": true
3413 }),
3414 },
3415 nodes: vec![
3416 WorkflowNode {
3417 node_id: "create_draft".to_string(),
3418 capability_id: "content.comments.create-comment-draft".to_string(),
3419 capability_version: "1.0.0".to_string(),
3420 input: WorkflowNodeInput {
3421 from_workflow_input: vec!["comment_text".to_string()],
3422 },
3423 output: WorkflowNodeOutput {
3424 to_workflow_state: vec!["draft_id".to_string()],
3425 publish_to_state_as: None,
3426 },
3427 },
3428 WorkflowNode {
3429 node_id: "validate_comment".to_string(),
3430 capability_id: "content.comments.validate-comment".to_string(),
3431 capability_version: "1.0.0".to_string(),
3432 input: WorkflowNodeInput {
3433 from_workflow_input: vec!["draft_id".to_string()],
3434 },
3435 output: WorkflowNodeOutput {
3436 to_workflow_state: vec!["draft_id".to_string()],
3437 publish_to_state_as: None,
3438 },
3439 },
3440 WorkflowNode {
3441 node_id: "persist_comment".to_string(),
3442 capability_id: "content.comments.persist-comment".to_string(),
3443 capability_version: "1.0.0".to_string(),
3444 input: WorkflowNodeInput {
3445 from_workflow_input: vec!["draft_id".to_string()],
3446 },
3447 output: WorkflowNodeOutput {
3448 to_workflow_state: vec!["comment_id".to_string()],
3449 publish_to_state_as: None,
3450 },
3451 },
3452 ],
3453 edges,
3454 start_node: "create_draft".to_string(),
3455 terminal_nodes: vec!["persist_comment".to_string()],
3456 output_projection: Vec::new(),
3457 tags: vec!["comments".to_string()],
3458 governing_spec: "007-workflow-registry-traversal".to_string(),
3459 }
3460 }
3461
3462 fn capability_contract(
3463 id: &str,
3464 emits: Vec<EventReference>,
3465 inputs: Value,
3466 outputs: Value,
3467 ) -> CapabilityContract {
3468 let has_emits = !emits.is_empty();
3474 CapabilityContract {
3475 kind: "capability_contract".to_string(),
3476 schema_version: "1.0.0".to_string(),
3477 id: id.to_string(),
3478 namespace: "content.comments".to_string(),
3479 name: id.rsplit('.').next().unwrap_or("capability").to_string(),
3480 version: "1.0.0".to_string(),
3481 lifecycle: Lifecycle::Active,
3482 owner: Owner {
3483 team: "comments".to_string(),
3484 contact: "comments@example.com".to_string(),
3485 },
3486 summary: "workflow fixture capability".to_string(),
3487 description: "workflow fixture capability used in runtime tests".to_string(),
3488 inputs: SchemaContainer { schema: inputs },
3489 outputs: SchemaContainer { schema: outputs },
3490 preconditions: vec![Condition {
3491 id: "precondition".to_string(),
3492 description: "must be valid".to_string(),
3493 }],
3494 postconditions: vec![Condition {
3495 id: "postcondition".to_string(),
3496 description: "must produce output".to_string(),
3497 }],
3498 side_effects: vec![SideEffect {
3499 kind: SideEffectKind::MemoryOnly,
3500 description: "memory only".to_string(),
3501 }],
3502 emits,
3503 consumes: Vec::new(),
3504 permissions: vec![IdReference {
3505 id: "permission".to_string(),
3506 }],
3507 execution: Execution {
3508 binary_format: ContractBinaryFormat::Wasm,
3509 entrypoint: Entrypoint {
3510 kind: EntrypointKind::WasiCommand,
3511 command: "run".to_string(),
3512 },
3513 preferred_targets: vec![ExecutionTarget::Local],
3514 constraints: ExecutionConstraints {
3515 host_api_access: HostApiAccess::None,
3516 network_access: NetworkAccess::Forbidden,
3517 filesystem_access: FilesystemAccess::None,
3518 },
3519 },
3520 policies: Vec::new(),
3521 dependencies: Vec::new(),
3522 provenance: Provenance {
3523 source: ProvenanceSource::Greenfield,
3524 author: "Enrico".to_string(),
3525 created_at: "2026-03-27T00:00:00Z".to_string(),
3526 spec_ref: Some("007-workflow-registry-traversal".to_string()),
3527 adr_refs: Vec::new(),
3528 exception_refs: Vec::new(),
3529 },
3530 evidence: vec![ValidationEvidence {
3531 evidence_id: "evidence".to_string(),
3532 evidence_type: EvidenceType::ContractValidation,
3533 status: EvidenceStatus::Passed,
3534 }],
3535 service_type: if has_emits {
3536 ServiceType::Subscribable
3537 } else {
3538 ServiceType::Stateless
3539 },
3540 permitted_targets: vec![
3541 ExecutionTarget::Local,
3542 ExecutionTarget::Cloud,
3543 ExecutionTarget::Edge,
3544 ExecutionTarget::Device,
3545 ],
3546 event_trigger: if has_emits {
3547 Some(format!("{id}.triggered"))
3548 } else {
3549 None
3550 },
3551 connector_requirements: Vec::new(),
3552 state_schema: None,
3553 use_cases: Vec::new(),
3554 risk: traverse_contracts::default_risk_metadata(),
3555 }
3556 }
3557
3558 fn valid_workflow_request() -> WorkflowExecutionRequest {
3559 WorkflowExecutionRequest {
3560 kind: "workflow_execution_request".to_string(),
3561 schema_version: "1.0.0".to_string(),
3562 request_id: "workflow-request".to_string(),
3563 workflow_id: "content.comments.publish-comment".to_string(),
3564 workflow_version: "1.0.0".to_string(),
3565 scope: WorkflowLookupScope::PublicOnly,
3566 input: json!({"comment_text": "hello"}),
3567 governing_spec: "007-workflow-registry-traversal".to_string(),
3568 }
3569 }
3570
3571 fn valid_runtime_request() -> RuntimeRequest {
3572 RuntimeRequest {
3573 kind: "runtime_request".to_string(),
3574 schema_version: "1.0.0".to_string(),
3575 request_id: "runtime-request".to_string(),
3576 intent: RuntimeIntent {
3577 capability_id: Some("content.comments.publish-comment".to_string()),
3578 capability_version: Some("1.0.0".to_string()),
3579 version_range: None,
3580 intent_key: None,
3581 },
3582 input: json!({"comment_text": "hello"}),
3583 lookup: RuntimeLookup {
3584 scope: RuntimeLookupScope::PublicOnly,
3585 allow_ambiguity: false,
3586 },
3587 context: RuntimeContext {
3588 requested_target: crate::PlacementTarget::Local,
3589 correlation_id: None,
3590 caller: None,
3591 traceparent: None,
3592 tracestate: None,
3593 metadata: None,
3594 identity: None,
3595 },
3596 governing_spec: "006-runtime-request-execution".to_string(),
3597 }
3598 }
3599
3600 struct WorkflowExecutor;
3601
3602 impl LocalExecutor for WorkflowExecutor {
3603 fn execute(
3604 &self,
3605 capability: &ResolvedCapability,
3606 _input: &Value,
3607 ) -> Result<LocalExecutionOutput, LocalExecutionFailure> {
3608 let (value, emitted_events) = match capability.record.id.as_str() {
3609 "content.comments.create-comment-draft" => (
3610 json!({"draft_id": "draft-1"}),
3611 vec![sample_traverse_event(
3612 "content.comments.draft-created",
3613 "1.0.0",
3614 )],
3615 ),
3616 "content.comments.validate-comment" => (
3617 json!({"draft_id": "draft-1"}),
3618 vec![sample_traverse_event("content.comments.validated", "1.0.0")],
3619 ),
3620 "content.comments.persist-comment" => {
3621 (json!({"comment_id": "comment-1"}), Vec::new())
3622 }
3623 _ => (json!({}), Vec::new()),
3624 };
3625 Ok(LocalExecutionOutput {
3626 value,
3627 emitted_events,
3628 })
3629 }
3630 }
3631
3632 struct FailingWorkflowExecutor;
3633
3634 impl LocalExecutor for FailingWorkflowExecutor {
3635 fn execute(
3636 &self,
3637 _capability: &ResolvedCapability,
3638 _input: &Value,
3639 ) -> Result<LocalExecutionOutput, LocalExecutionFailure> {
3640 Err(LocalExecutionFailure {
3641 code: LocalExecutionFailureCode::ExecutionFailed,
3642 message: "boom".to_string(),
3643 })
3644 }
3645 }
3646
3647 struct MissingEventWorkflowExecutor;
3648
3649 struct BadOutputWorkflowExecutor;
3650
3651 impl LocalExecutor for MissingEventWorkflowExecutor {
3652 fn execute(
3653 &self,
3654 capability: &ResolvedCapability,
3655 _input: &Value,
3656 ) -> Result<LocalExecutionOutput, LocalExecutionFailure> {
3657 let (value, emitted_events) = match capability.record.id.as_str() {
3658 "content.comments.create-comment-draft" => (
3659 json!({"draft_id": "draft-1"}),
3660 vec![sample_traverse_event(
3661 "content.comments.draft-created",
3662 "1.0.0",
3663 )],
3664 ),
3665 "content.comments.validate-comment" => (json!({"draft_id": "draft-1"}), Vec::new()),
3666 "content.comments.persist-comment" => {
3667 (json!({"comment_id": "comment-1"}), Vec::new())
3668 }
3669 _ => (json!({}), Vec::new()),
3670 };
3671 Ok(LocalExecutionOutput {
3672 value,
3673 emitted_events,
3674 })
3675 }
3676 }
3677
3678 impl LocalExecutor for BadOutputWorkflowExecutor {
3679 fn execute(
3680 &self,
3681 capability: &ResolvedCapability,
3682 _input: &Value,
3683 ) -> Result<LocalExecutionOutput, LocalExecutionFailure> {
3684 let (value, emitted_events) = match capability.record.id.as_str() {
3685 "content.comments.create-comment-draft" => (
3686 json!({}),
3687 vec![sample_traverse_event(
3688 "content.comments.draft-created",
3689 "1.0.0",
3690 )],
3691 ),
3692 _ => (json!({}), Vec::new()),
3693 };
3694 Ok(LocalExecutionOutput {
3695 value,
3696 emitted_events,
3697 })
3698 }
3699 }
3700
3701 struct UndeclaredEventWorkflowExecutor;
3702
3703 impl LocalExecutor for UndeclaredEventWorkflowExecutor {
3704 fn execute(
3705 &self,
3706 _capability: &ResolvedCapability,
3707 _input: &Value,
3708 ) -> Result<LocalExecutionOutput, LocalExecutionFailure> {
3709 Ok(LocalExecutionOutput {
3715 value: json!({"draft_id": "draft-1"}),
3716 emitted_events: vec![sample_traverse_event(
3717 "content.comments.undeclared-event",
3718 "1.0.0",
3719 )],
3720 })
3721 }
3722 }
3723
3724 fn register_capability_ok(registry: &mut CapabilityRegistry, request: CapabilityRegistration) {
3725 match registry.register(request) {
3726 Ok(_) => {}
3727 Err(error) => unreachable!("{error:?}"),
3728 }
3729 }
3730
3731 fn register_workflow_ok(
3732 registry: &mut WorkflowRegistry,
3733 capabilities: &CapabilityRegistry,
3734 request: WorkflowRegistration,
3735 ) {
3736 match registry.register(capabilities, request) {
3737 Ok(_) => {}
3738 Err(error) => unreachable!("{error:?}"),
3739 }
3740 }
3741
3742 #[test]
3743 fn helper_guards_cover_unreachable_branches() {
3744 let capability_panic = std::panic::catch_unwind(|| {
3745 register_capability_ok(
3746 &mut CapabilityRegistry::new(),
3747 CapabilityRegistration {
3748 scope: RegistryScope::Public,
3749 contract: capability_contract("bad", Vec::new(), json!({}), json!({})),
3750 contract_path: String::new(),
3751 artifact: workflow_artifact_record("bad", "1.0.0", "artifact"),
3752 registered_at: String::new(),
3753 tags: Vec::new(),
3754 composability: ComposabilityMetadata {
3755 kind: CompositionKind::Atomic,
3756 patterns: Vec::new(),
3757 provides: Vec::new(),
3758 requires: Vec::new(),
3759 },
3760 governing_spec: "005-capability-registry".to_string(),
3761 validator_version: "validator".to_string(),
3762 },
3763 );
3764 });
3765 assert!(capability_panic.is_err());
3766
3767 let workflow_panic = std::panic::catch_unwind(|| {
3768 register_workflow_ok(
3769 &mut WorkflowRegistry::new(),
3770 &CapabilityRegistry::new(),
3771 WorkflowRegistration {
3772 scope: RegistryScope::Public,
3773 definition: workflow_definition_fixture(
3774 None,
3775 Some(WorkflowEdge {
3776 edge_id: "direct".to_string(),
3777 from: "create_draft".to_string(),
3778 to: "validate_comment".to_string(),
3779 trigger: WorkflowEdgeTrigger::Direct,
3780 event: None,
3781 predicate: None,
3782 }),
3783 ),
3784 workflow_path: String::new(),
3785 registered_at: String::new(),
3786 validator_version: "validator".to_string(),
3787 },
3788 );
3789 });
3790 assert!(workflow_panic.is_err());
3791 }
3792
3793 fn resolved_workflow(definition: WorkflowDefinition) -> ResolvedWorkflow {
3794 ResolvedWorkflow {
3795 record: WorkflowRegistryRecord {
3796 scope: RegistryScope::Public,
3797 id: definition.id.clone(),
3798 version: definition.version.clone(),
3799 lifecycle: definition.lifecycle.clone(),
3800 owner: definition.owner.clone(),
3801 workflow_path: "workflows/manual.json".to_string(),
3802 workflow_digest: "digest".to_string(),
3803 registered_at: "2026-03-27T00:00:00Z".to_string(),
3804 governing_spec: "007-workflow-registry-traversal".to_string(),
3805 validator_version: "validator".to_string(),
3806 evidence: traverse_registry::WorkflowRegistrationEvidence {
3807 evidence_id: "evidence".to_string(),
3808 workflow_id: definition.id.clone(),
3809 workflow_version: definition.version.clone(),
3810 scope: RegistryScope::Public,
3811 governing_spec: "007-workflow-registry-traversal".to_string(),
3812 validator_version: "validator".to_string(),
3813 produced_at: "2026-03-27T00:00:00Z".to_string(),
3814 result: traverse_registry::WorkflowRegistrationResult::Passed,
3815 },
3816 },
3817 index_entry: traverse_registry::WorkflowDiscoveryIndexEntry {
3818 scope: RegistryScope::Public,
3819 id: definition.id.clone(),
3820 version: definition.version.clone(),
3821 lifecycle: definition.lifecycle.clone(),
3822 owner: definition.owner.clone(),
3823 summary: definition.summary.clone(),
3824 tags: definition.tags.clone(),
3825 participating_capabilities: definition
3826 .nodes
3827 .iter()
3828 .map(|node| node.capability_id.clone())
3829 .collect(),
3830 events_used: Vec::new(),
3831 start_node: definition.start_node.clone(),
3832 terminal_nodes: definition.terminal_nodes.clone(),
3833 registered_at: "2026-03-27T00:00:00Z".to_string(),
3834 },
3835 definition,
3836 }
3837 }
3838}