Skip to main content

traverse_runtime/
parallel_proposal.rs

1//! Bounded deterministic parallel proposal execution (spec
2//! `110-bounded-parallel-workflow-scheduling`, P2, ADR-0042).
3//!
4//! Extends the P1 sequential proposal executor in `crate::proposal` with a
5//! wave-based concurrent dispatcher over the same `CanonicalProposal`/
6//! `ResolvedProposalNode` shapes. A [`traverse_contracts::ParallelSchedule`]
7//! (computed by `traverse_contracts::compute_parallel_schedule`) levelizes
8//! the already-validated acyclic graph into waves of node ids whose
9//! dependencies are satisfied by earlier waves; this module authorizes and
10//! executes that schedule.
11//!
12//! FR-004a (spec 110): the first P2 implementation permits a wave with more
13//! than one member only when every member's declared `effect_class` is
14//! `pure_read` — [`enforce_pure_read_only_parallelism`] checks this before
15//! any dispatch. Once authorized, each wave runs on real OS threads via
16//! [`std::thread::scope`], bounded to `max_concurrent_nodes` per batch;
17//! outcomes are folded back into the trace in the wave's lexicographic
18//! order (not completion order), so the observable trace is deterministic
19//! regardless of real scheduling (FR-002).
20//!
21//! Wall-clock and payload-size bounds (FR-001, FR-005) are checked *between*
22//! waves, before committing to the next one — Rust gives no safe way to
23//! preemptively interrupt an in-flight OS thread without `unsafe`, and
24//! FR-004a already restricts concurrent work to side-effect-free local reads,
25//! so a wave that is already dispatched is always allowed to finish; a
26//! budget that is already exhausted simply stops further waves from
27//! starting, reported as [`traverse_contracts::CanonicalProposal`]'s trace
28//! terminal state `cancelled`.
29
30use serde::Serialize;
31use serde_json::Value;
32use std::collections::HashMap;
33use std::time::{Duration, Instant};
34
35use traverse_contracts::{CanonicalProposal, EffectClass, ParallelSchedule, ProposalNode};
36
37use crate::proposal::{
38    AuthorizationSummary, ProposalNodeOutcome, ProposalNodeStatus, ProposalTerminalState,
39    ProposalTrace, ResolvedProposalNode, assemble_node_input, build_node_execution_request,
40};
41use crate::{Runtime, RuntimeResultStatus};
42
43// ---------------------------------------------------------------------------
44// FR-004a: pure_read-only concurrency authorization
45// ---------------------------------------------------------------------------
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
48pub struct ParallelAuthorizationError {
49    pub code: ParallelAuthorizationErrorCode,
50    pub message: String,
51    pub path: String,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
55#[serde(rename_all = "snake_case")]
56pub enum ParallelAuthorizationErrorCode {
57    ConcurrentSideEffectDenied,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct ParallelAuthorizationFailure {
62    pub errors: Vec<ParallelAuthorizationError>,
63}
64
65/// Enforces spec 110 FR-004a: the first P2 implementation permits parallel
66/// execution only for `pure_read` nodes. Any wave with more than one member
67/// is denied outright unless every member in it is `pure_read`.
68///
69/// # Errors
70///
71/// Returns [`ParallelAuthorizationFailure`] listing every offending node.
72pub fn enforce_pure_read_only_parallelism(
73    schedule: &ParallelSchedule,
74    resolved_nodes: &[ResolvedProposalNode],
75) -> Result<(), ParallelAuthorizationFailure> {
76    let effect_class_by_node: HashMap<&str, EffectClass> = resolved_nodes
77        .iter()
78        .map(|node| (node.node_id.as_str(), node.contract.risk.effect_class))
79        .collect();
80
81    let mut errors = Vec::new();
82    for (wave_index, wave) in schedule.waves.iter().enumerate() {
83        if wave.len() <= 1 {
84            continue;
85        }
86        for node_id in wave {
87            if effect_class_by_node.get(node_id.as_str()) != Some(&EffectClass::PureRead) {
88                errors.push(ParallelAuthorizationError {
89                    code: ParallelAuthorizationErrorCode::ConcurrentSideEffectDenied,
90                    message: format!(
91                        "node '{node_id}' does not have effect_class pure_read and cannot run \
92                         concurrently with other nodes in wave {wave_index}"
93                    ),
94                    path: format!("$.schedule.waves[{wave_index}]"),
95                });
96            }
97        }
98    }
99
100    if errors.is_empty() {
101        Ok(())
102    } else {
103        Err(ParallelAuthorizationFailure { errors })
104    }
105}
106
107// ---------------------------------------------------------------------------
108// Execution-time bounds (spec 110 FR-001, FR-005): wall time and payload size
109// ---------------------------------------------------------------------------
110
111/// Execution-time bounds that structural schedule validation cannot express
112/// (spec 110 FR-001: "time, memory" bounds). Checked between waves.
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub struct ParallelExecutionLimits {
115    /// Total wall-clock budget for the whole parallel execution. Checked
116    /// before starting each wave, not preemptively mid-wave.
117    pub max_wall_time: Duration,
118    /// Max total serialized JSON byte size of one wave's assembled node
119    /// inputs — a bounded, honest proxy for a per-wave memory budget.
120    pub max_wave_payload_bytes: usize,
121    /// Max node executions the runtime dispatches at once within a wave —
122    /// the execution-time enforcement of
123    /// `ParallelScheduleLimits::max_concurrent_nodes`.
124    pub max_concurrent_nodes: usize,
125}
126
127pub const DEFAULT_MAX_WALL_TIME_MS: u64 = 30_000;
128pub const DEFAULT_MAX_WAVE_PAYLOAD_BYTES: usize = 1_048_576;
129
130impl Default for ParallelExecutionLimits {
131    fn default() -> Self {
132        Self {
133            max_wall_time: Duration::from_millis(DEFAULT_MAX_WALL_TIME_MS),
134            max_wave_payload_bytes: DEFAULT_MAX_WAVE_PAYLOAD_BYTES,
135            max_concurrent_nodes: traverse_contracts::DEFAULT_MAX_CONCURRENT_NODES,
136        }
137    }
138}
139
140// ---------------------------------------------------------------------------
141// Execution
142// ---------------------------------------------------------------------------
143
144/// Executes an authorized [`ParallelSchedule`] wave by wave, dispatching
145/// each wave's nodes concurrently (bounded to
146/// `limits.max_concurrent_nodes` batches) when the wave has more than one
147/// member, and folding results back in lexicographic order regardless of
148/// real completion order (spec 110 FR-002).
149///
150/// Stops advancing to further waves — but never interrupts an
151/// already-dispatched one — at the first node failure, exhausted wall-time
152/// budget, or exceeded wave payload budget (spec 110 FR-005, FR-008
153/// carried over from P1).
154#[must_use]
155#[allow(clippy::too_many_lines)]
156pub fn execute_parallel_proposal<E: crate::LocalExecutor>(
157    runtime: &Runtime<E>,
158    canonical: &CanonicalProposal,
159    schedule: &ParallelSchedule,
160    authorization: AuthorizationSummary,
161    proposal_digest: &str,
162    snapshot_digest: &str,
163    limits: &ParallelExecutionLimits,
164) -> ProposalTrace
165where
166    Runtime<E>: Sync,
167{
168    let nodes_by_id: HashMap<&str, &ProposalNode> = canonical
169        .proposal
170        .nodes
171        .iter()
172        .map(|node| (node.node_id.as_str(), node))
173        .collect();
174
175    let mut outputs: HashMap<String, Value> = HashMap::new();
176    let mut outcomes: Vec<ProposalNodeOutcome> = Vec::with_capacity(canonical.proposal.nodes.len());
177    let mut failed = false;
178    let mut cancelled = false;
179    let start = Instant::now();
180    let batch_size = limits.max_concurrent_nodes.max(1);
181
182    for wave in &schedule.waves {
183        if failed || cancelled {
184            push_skipped_wave(&mut outcomes, wave, &nodes_by_id);
185            continue;
186        }
187        if start.elapsed() > limits.max_wall_time {
188            cancelled = true;
189            push_skipped_wave(&mut outcomes, wave, &nodes_by_id);
190            continue;
191        }
192
193        let wave_inputs: Vec<(String, Value)> = wave
194            .iter()
195            .map(|node_id| {
196                (
197                    node_id.clone(),
198                    assemble_node_input(canonical, node_id, &outputs),
199                )
200            })
201            .collect();
202        let wave_payload_bytes: usize = wave_inputs
203            .iter()
204            .map(|(_, input)| input.to_string().len())
205            .sum();
206        if wave_payload_bytes > limits.max_wave_payload_bytes {
207            cancelled = true;
208            push_skipped_wave(&mut outcomes, wave, &nodes_by_id);
209            continue;
210        }
211
212        for batch in wave_inputs.chunks(batch_size) {
213            for (node_id, outcome, output) in
214                dispatch_batch(runtime, canonical, &nodes_by_id, batch)
215            {
216                if outcome.status == ProposalNodeStatus::Failed {
217                    failed = true;
218                }
219                if let Some(output) = output {
220                    outputs.insert(node_id, output);
221                }
222                outcomes.push(outcome);
223            }
224        }
225    }
226
227    let terminal_state = if failed {
228        ProposalTerminalState::Failed
229    } else if cancelled {
230        ProposalTerminalState::Cancelled
231    } else {
232        ProposalTerminalState::Succeeded
233    };
234
235    ProposalTrace {
236        proposal_id: canonical.proposal.proposal_id.clone(),
237        proposal_digest: proposal_digest.to_string(),
238        snapshot_digest: snapshot_digest.to_string(),
239        authorization,
240        node_outcomes: outcomes,
241        mapping_paths: canonical
242            .proposal
243            .mappings
244            .iter()
245            .map(|m| (m.source_path.clone(), m.target_path.clone()))
246            .collect(),
247        terminal_state,
248    }
249}
250
251/// Dispatches one bounded batch of a wave concurrently on real OS threads
252/// and returns each entry's outcome (plus its output to store, on success)
253/// in the batch's original lexicographic order, independent of actual
254/// completion order. A node id absent from `nodes_by_id` (only reachable by
255/// hand-constructing a [`ParallelSchedule`] that disagrees with `canonical`
256/// — never produced by `traverse_contracts::compute_parallel_schedule`) is
257/// silently skipped. A panicking executor is surfaced as a `Failed` outcome
258/// rather than silently dropped, matching this crate's fail-closed
259/// convention for host-side faults.
260fn dispatch_batch<'a, E: crate::LocalExecutor>(
261    runtime: &Runtime<E>,
262    canonical: &CanonicalProposal,
263    nodes_by_id: &HashMap<&'a str, &'a ProposalNode>,
264    batch: &[(String, Value)],
265) -> Vec<(String, ProposalNodeOutcome, Option<Value>)>
266where
267    Runtime<E>: Sync,
268{
269    let dispatchable: Vec<(&str, &'a ProposalNode, &Value)> = batch
270        .iter()
271        .filter_map(|(node_id, input)| {
272            nodes_by_id
273                .get(node_id.as_str())
274                .map(|node| (node_id.as_str(), *node, input))
275        })
276        .collect();
277
278    let joined: Vec<(
279        String,
280        &'a ProposalNode,
281        std::thread::Result<crate::RuntimeExecutionOutcome>,
282    )> = std::thread::scope(|scope| {
283        let handles: Vec<(
284            String,
285            &'a ProposalNode,
286            std::thread::ScopedJoinHandle<'_, crate::RuntimeExecutionOutcome>,
287        )> = dispatchable
288            .iter()
289            .map(|(node_id, node, input)| {
290                let owned_node_id = (*node_id).to_string();
291                let spawn_node_id = owned_node_id.clone();
292                let node = *node;
293                let input = (*input).clone();
294                let handle = scope.spawn(move || {
295                    let request =
296                        build_node_execution_request(canonical, node, &spawn_node_id, input);
297                    runtime.execute(request)
298                });
299                (owned_node_id, node, handle)
300            })
301            .collect();
302        handles
303            .into_iter()
304            .map(|(node_id, node, handle)| (node_id, node, handle.join()))
305            .collect()
306    });
307
308    joined
309        .into_iter()
310        .map(|(node_id, node, joined_result)| match joined_result {
311            Ok(outcome) => match outcome.result.status {
312                RuntimeResultStatus::Completed => (
313                    node_id.clone(),
314                    succeeded_outcome(&node_id, node),
315                    outcome.result.output.clone(),
316                ),
317                RuntimeResultStatus::Error => (
318                    node_id.clone(),
319                    failed_outcome(&node_id, node, &outcome),
320                    None,
321                ),
322            },
323            Err(_) => (node_id.clone(), panicked_outcome(&node_id, node), None),
324        })
325        .collect()
326}
327
328fn succeeded_outcome(node_id: &str, node: &ProposalNode) -> ProposalNodeOutcome {
329    ProposalNodeOutcome {
330        node_id: node_id.to_string(),
331        capability_id: node.capability_id.clone(),
332        capability_version: node.capability_version.clone(),
333        artifact_digest: node.artifact_digest.clone(),
334        status: ProposalNodeStatus::Succeeded,
335        error_code: None,
336    }
337}
338
339fn failed_outcome(
340    node_id: &str,
341    node: &ProposalNode,
342    outcome: &crate::RuntimeExecutionOutcome,
343) -> ProposalNodeOutcome {
344    ProposalNodeOutcome {
345        node_id: node_id.to_string(),
346        capability_id: node.capability_id.clone(),
347        capability_version: node.capability_version.clone(),
348        artifact_digest: node.artifact_digest.clone(),
349        status: ProposalNodeStatus::Failed,
350        error_code: outcome
351            .result
352            .error
353            .as_ref()
354            .map(|error| format!("{:?}", error.code)),
355    }
356}
357
358/// A node execution thread panicked. Surfaced as a `Failed` outcome — never
359/// silently dropped — matching this crate's fail-closed convention for
360/// host-side faults (e.g. `events/broker.rs`'s poisoned-lock handling).
361fn panicked_outcome(node_id: &str, node: &ProposalNode) -> ProposalNodeOutcome {
362    ProposalNodeOutcome {
363        node_id: node_id.to_string(),
364        capability_id: node.capability_id.clone(),
365        capability_version: node.capability_version.clone(),
366        artifact_digest: node.artifact_digest.clone(),
367        status: ProposalNodeStatus::Failed,
368        error_code: Some("executor_panicked".to_string()),
369    }
370}
371
372fn push_skipped_wave(
373    outcomes: &mut Vec<ProposalNodeOutcome>,
374    wave: &[String],
375    nodes_by_id: &HashMap<&str, &ProposalNode>,
376) {
377    for node_id in wave {
378        let Some(node) = nodes_by_id.get(node_id.as_str()) else {
379            continue;
380        };
381        outcomes.push(ProposalNodeOutcome {
382            node_id: node_id.clone(),
383            capability_id: node.capability_id.clone(),
384            capability_version: node.capability_version.clone(),
385            artifact_digest: node.artifact_digest.clone(),
386            status: ProposalNodeStatus::SkippedAfterEarlierFailure,
387            error_code: None,
388        });
389    }
390}
391
392#[cfg(test)]
393#[allow(clippy::expect_used)]
394#[allow(clippy::panic)]
395mod tests {
396    use super::*;
397    use crate::security::RuntimeSecurityConfig;
398    use crate::{
399        LocalExecutionFailure, LocalExecutionFailureCode, LocalExecutionOutput, LocalExecutor,
400    };
401    use serde_json::json;
402    use std::collections::{HashMap as StdHashMap, HashSet};
403    use std::sync::{Arc, Mutex};
404    use traverse_contracts::{
405        BinaryFormat as ContractBinaryFormat, CapabilityContract, DataFlowPolicy, DeterminismClass,
406        Entrypoint, EntrypointKind, Execution, ExecutionConstraints, ExecutionTarget,
407        FilesystemAccess, HostApiAccess, Lifecycle, ManifestReference, NetworkAccess, Owner,
408        ParallelScheduleLimits, ProposalEdge, ProposalLimits, ReliabilityMetadata, RiskMetadata,
409        SchemaContainer, ServiceType, SideEffect, SideEffectKind, WorkflowProposal,
410        canonicalize_proposal, compute_parallel_schedule,
411    };
412    use traverse_registry::{
413        ArtifactDigests, BinaryFormat as RegistryBinaryFormat, BinaryReference,
414        CapabilityArtifactRecord, CapabilityRegistration, CapabilityRegistry,
415        ComposabilityMetadata, CompositionKind, CompositionPattern, ImplementationKind,
416        RegistryProvenance, RegistryScope, SourceKind, SourceReference,
417    };
418
419    fn risk(effect_class: EffectClass) -> RiskMetadata {
420        RiskMetadata {
421            effect_class,
422            determinism_class: DeterminismClass::Deterministic,
423            data_flow: DataFlowPolicy::default(),
424            reliability: ReliabilityMetadata {
425                idempotency_required: false,
426                retryable: true,
427                compensation_available: false,
428            },
429        }
430    }
431
432    fn contract(capability_id: &str, effect_class: EffectClass) -> CapabilityContract {
433        let (namespace, name) = capability_id
434            .rsplit_once('.')
435            .unwrap_or(("test", capability_id));
436        CapabilityContract {
437            kind: "capability_contract".to_string(),
438            schema_version: "1.0.0".to_string(),
439            id: capability_id.to_string(),
440            namespace: namespace.to_string(),
441            name: name.to_string(),
442            version: "1.0.0".to_string(),
443            lifecycle: Lifecycle::Active,
444            owner: Owner {
445                team: "traverse-core".to_string(),
446                contact: "enrico.piovesan10@gmail.com".to_string(),
447            },
448            summary: "Test capability for parallel proposal scheduling.".to_string(),
449            description: "Portable test capability used to validate parallel scheduling."
450                .to_string(),
451            inputs: SchemaContainer { schema: json!({}) },
452            outputs: SchemaContainer { schema: json!({}) },
453            preconditions: Vec::new(),
454            postconditions: Vec::new(),
455            side_effects: vec![SideEffect {
456                kind: SideEffectKind::MemoryOnly,
457                description: "No durable side effect.".to_string(),
458            }],
459            emits: Vec::new(),
460            consumes: Vec::new(),
461            permissions: Vec::new(),
462            execution: Execution {
463                binary_format: ContractBinaryFormat::Wasm,
464                entrypoint: Entrypoint {
465                    kind: EntrypointKind::WasiCommand,
466                    command: "run".to_string(),
467                },
468                preferred_targets: vec![ExecutionTarget::Local],
469                constraints: ExecutionConstraints {
470                    host_api_access: HostApiAccess::None,
471                    network_access: NetworkAccess::Forbidden,
472                    filesystem_access: FilesystemAccess::None,
473                },
474            },
475            policies: Vec::new(),
476            dependencies: Vec::new(),
477            provenance: traverse_contracts::Provenance {
478                source: traverse_contracts::ProvenanceSource::Greenfield,
479                author: "test".to_string(),
480                created_at: "2026-08-23T00:00:00Z".to_string(),
481                spec_ref: None,
482                adr_refs: Vec::new(),
483                exception_refs: Vec::new(),
484            },
485            evidence: Vec::new(),
486            service_type: ServiceType::Stateless,
487            permitted_targets: vec![ExecutionTarget::Local],
488            event_trigger: None,
489            connector_requirements: Vec::new(),
490            state_schema: None,
491            use_cases: Vec::new(),
492            risk: risk(effect_class),
493        }
494    }
495
496    fn artifact(digest: &str) -> CapabilityArtifactRecord {
497        CapabilityArtifactRecord {
498            artifact_ref: format!("artifact:{digest}"),
499            implementation_kind: ImplementationKind::Executable,
500            source: SourceReference {
501                kind: SourceKind::Git,
502                location: "https://example.invalid/repo".to_string(),
503            },
504            binary: Some(BinaryReference {
505                format: RegistryBinaryFormat::Wasm,
506                location: format!("artifacts/{digest}/capability.wasm"),
507                signature: None,
508            }),
509            workflow_ref: None,
510            digests: ArtifactDigests {
511                source_digest: format!("src-{digest}"),
512                binary_digest: Some(digest.to_string()),
513            },
514            provenance: RegistryProvenance {
515                source: "test".to_string(),
516                author: "test".to_string(),
517                created_at: "2026-08-23T00:00:00Z".to_string(),
518            },
519        }
520    }
521
522    fn registry_with(
523        entries: Vec<(CapabilityContract, CapabilityArtifactRecord)>,
524    ) -> CapabilityRegistry {
525        let mut registry = CapabilityRegistry::new();
526        for (contract, artifact) in entries {
527            let outcome = registry.register(CapabilityRegistration {
528                scope: RegistryScope::Public,
529                contract,
530                contract_path: "registry/test/contract.json".to_string(),
531                artifact,
532                registered_at: "2026-08-23T00:00:00Z".to_string(),
533                tags: Vec::new(),
534                composability: ComposabilityMetadata {
535                    kind: CompositionKind::Atomic,
536                    patterns: vec![CompositionPattern::Sequential],
537                    provides: Vec::new(),
538                    requires: Vec::new(),
539                },
540                governing_spec: "005-capability-registry".to_string(),
541                validator_version: "0.1.0".to_string(),
542            });
543            assert!(outcome.is_ok(), "registration must succeed: {outcome:?}");
544        }
545        registry
546    }
547
548    fn node(node_id: &str, capability_id: &str) -> ProposalNode {
549        ProposalNode {
550            node_id: node_id.to_string(),
551            capability_id: capability_id.to_string(),
552            capability_version: "1.0.0".to_string(),
553            artifact_digest: format!("digest-{node_id}"),
554        }
555    }
556
557    fn resolved(node_id: &str, effect_class: EffectClass) -> ResolvedProposalNode {
558        ResolvedProposalNode {
559            node_id: node_id.to_string(),
560            contract: contract(&format!("test.{node_id}"), effect_class),
561        }
562    }
563
564    /// a fans out to b and c (both independently mapped from a's output),
565    /// which both feed the join node d.
566    fn diamond_workflow_proposal() -> WorkflowProposal {
567        WorkflowProposal {
568            kind: "workflow_proposal".to_string(),
569            schema_version: "1.0.0".to_string(),
570            proposal_id: "proposal-p2-001".to_string(),
571            workspace_id: "workspace-001".to_string(),
572            app_manifest: ManifestReference {
573                app_id: "test-app".to_string(),
574                app_version: "1.0.0".to_string(),
575                manifest_digest: "sha256:manifest-digest".to_string(),
576            },
577            nodes: vec![
578                node("a", "test.a"),
579                node("b", "test.b"),
580                node("c", "test.c"),
581                node("d", "test.d"),
582            ],
583            edges: vec![
584                ProposalEdge {
585                    from_node_id: "a".to_string(),
586                    to_node_id: "b".to_string(),
587                },
588                ProposalEdge {
589                    from_node_id: "a".to_string(),
590                    to_node_id: "c".to_string(),
591                },
592                ProposalEdge {
593                    from_node_id: "b".to_string(),
594                    to_node_id: "d".to_string(),
595                },
596                ProposalEdge {
597                    from_node_id: "c".to_string(),
598                    to_node_id: "d".to_string(),
599                },
600            ],
601            mappings: Vec::new(),
602            initial_input: json!({}),
603        }
604    }
605
606    fn diamond_canonical() -> CanonicalProposal {
607        canonicalize_proposal(diamond_workflow_proposal(), &ProposalLimits::default())
608            .expect("diamond proposal must canonicalize")
609    }
610
611    fn diamond_resolved_nodes(non_read_effect: Option<&str>) -> Vec<ResolvedProposalNode> {
612        ["a", "b", "c", "d"]
613            .iter()
614            .map(|id| {
615                let effect_class = if Some(*id) == non_read_effect {
616                    EffectClass::ExternalEffect
617                } else {
618                    EffectClass::PureRead
619                };
620                resolved(id, effect_class)
621            })
622            .collect()
623    }
624
625    // -- FR-004a: pure_read-only concurrency authorization -------------------
626
627    #[test]
628    fn allows_a_diamond_schedule_when_every_concurrent_wave_is_pure_read() -> Result<(), String> {
629        let canonical = diamond_canonical();
630        let schedule = compute_parallel_schedule(&canonical, &ParallelScheduleLimits::default())
631            .map_err(|e| format!("{e:?}"))?;
632        enforce_pure_read_only_parallelism(&schedule, &diamond_resolved_nodes(None))
633            .map_err(|e| format!("{e:?}"))
634    }
635
636    #[test]
637    fn denies_a_concurrent_wave_containing_a_non_pure_read_node() -> Result<(), String> {
638        let canonical = diamond_canonical();
639        let schedule = compute_parallel_schedule(&canonical, &ParallelScheduleLimits::default())
640            .map_err(|e| format!("{e:?}"))?;
641        let failure =
642            enforce_pure_read_only_parallelism(&schedule, &diamond_resolved_nodes(Some("c")))
643                .expect_err("a non-pure_read node in a concurrent wave must be denied");
644        assert!(
645            failure
646                .errors
647                .iter()
648                .any(|e| e.code == ParallelAuthorizationErrorCode::ConcurrentSideEffectDenied)
649        );
650        Ok(())
651    }
652
653    #[test]
654    fn allows_a_non_pure_read_node_when_its_wave_has_no_sibling() -> Result<(), String> {
655        // b and c are pure_read and run concurrently in wave 1; a and d are
656        // singleton waves and may be any effect class.
657        let canonical = diamond_canonical();
658        let schedule = compute_parallel_schedule(&canonical, &ParallelScheduleLimits::default())
659            .map_err(|e| format!("{e:?}"))?;
660        enforce_pure_read_only_parallelism(&schedule, &diamond_resolved_nodes(Some("d")))
661            .map_err(|e| format!("{e:?}"))
662    }
663
664    // -- Execution -------------------------------------------------------------
665
666    #[derive(Default)]
667    struct ConcurrencyTracker {
668        current: Mutex<usize>,
669        max_seen: Mutex<usize>,
670    }
671
672    impl ConcurrencyTracker {
673        fn enter(&self) {
674            let mut current = self
675                .current
676                .lock()
677                .expect("tracker lock must not be poisoned");
678            *current += 1;
679            let mut max_seen = self
680                .max_seen
681                .lock()
682                .expect("tracker lock must not be poisoned");
683            if *current > *max_seen {
684                *max_seen = *current;
685            }
686        }
687
688        fn exit(&self) {
689            let mut current = self
690                .current
691                .lock()
692                .expect("tracker lock must not be poisoned");
693            *current -= 1;
694        }
695
696        fn max_seen(&self) -> usize {
697            *self
698                .max_seen
699                .lock()
700                .expect("tracker lock must not be poisoned")
701        }
702    }
703
704    #[derive(Default, Clone)]
705    struct ScriptedExecutor {
706        fail_capability_ids: HashSet<String>,
707        panic_capability_ids: HashSet<String>,
708        sleep_capability_ids: StdHashMap<String, std::time::Duration>,
709        concurrency: Arc<ConcurrencyTracker>,
710    }
711
712    impl LocalExecutor for ScriptedExecutor {
713        fn execute(
714            &self,
715            capability: &traverse_registry::ResolvedCapability,
716            input: &Value,
717        ) -> Result<LocalExecutionOutput, LocalExecutionFailure> {
718            self.concurrency.enter();
719            if self.panic_capability_ids.contains(&capability.contract.id) {
720                self.concurrency.exit();
721                panic!("scripted executor panic for test");
722            }
723            if let Some(duration) = self.sleep_capability_ids.get(&capability.contract.id) {
724                std::thread::sleep(*duration);
725            }
726            let result = if self.fail_capability_ids.contains(&capability.contract.id) {
727                Err(LocalExecutionFailure {
728                    code: LocalExecutionFailureCode::ExecutionFailed,
729                    message: "scripted failure".to_string(),
730                })
731            } else {
732                Ok(LocalExecutionOutput {
733                    value: json!({"node": capability.contract.id, "received": input.clone()}),
734                    emitted_events: Vec::new(),
735                })
736            };
737            self.concurrency.exit();
738            result
739        }
740    }
741
742    fn diamond_registry() -> CapabilityRegistry {
743        registry_with(vec![
744            (
745                contract("test.a", EffectClass::PureRead),
746                artifact("digest-a"),
747            ),
748            (
749                contract("test.b", EffectClass::PureRead),
750                artifact("digest-b"),
751            ),
752            (
753                contract("test.c", EffectClass::PureRead),
754                artifact("digest-c"),
755            ),
756            (
757                contract("test.d", EffectClass::PureRead),
758                artifact("digest-d"),
759            ),
760        ])
761    }
762
763    #[test]
764    fn executes_independent_branches_concurrently_with_a_deterministic_trace_order()
765    -> Result<(), String> {
766        let canonical = diamond_canonical();
767        let schedule = compute_parallel_schedule(&canonical, &ParallelScheduleLimits::default())
768            .map_err(|e| format!("{e:?}"))?;
769
770        let mut sleep_capability_ids = StdHashMap::new();
771        sleep_capability_ids.insert("test.c".to_string(), std::time::Duration::from_millis(20));
772        let executor = ScriptedExecutor {
773            sleep_capability_ids,
774            ..ScriptedExecutor::default()
775        };
776        let runtime = Runtime::new(diamond_registry(), executor)
777            .with_security_config(RuntimeSecurityConfig::development());
778
779        let trace = execute_parallel_proposal(
780            &runtime,
781            &canonical,
782            &schedule,
783            AuthorizationSummary {
784                automatic: true,
785                approval_token_id: None,
786            },
787            "digest",
788            "snapshot-digest",
789            &ParallelExecutionLimits::default(),
790        );
791
792        assert_eq!(trace.terminal_state, ProposalTerminalState::Succeeded);
793        // b finishes before c (c sleeps), but the trace order is always
794        // lexicographic (b, c), never completion order.
795        let node_order: Vec<&str> = trace
796            .node_outcomes
797            .iter()
798            .map(|o| o.node_id.as_str())
799            .collect();
800        assert_eq!(node_order, vec!["a", "b", "c", "d"]);
801        assert!(
802            trace
803                .node_outcomes
804                .iter()
805                .all(|o| o.status == ProposalNodeStatus::Succeeded)
806        );
807        Ok(())
808    }
809
810    #[test]
811    fn bounds_real_concurrency_to_the_configured_max_concurrent_nodes() -> Result<(), String> {
812        let canonical = diamond_canonical();
813        let schedule = compute_parallel_schedule(&canonical, &ParallelScheduleLimits::default())
814            .map_err(|e| format!("{e:?}"))?;
815
816        let mut sleep_capability_ids = StdHashMap::new();
817        sleep_capability_ids.insert("test.b".to_string(), std::time::Duration::from_millis(15));
818        sleep_capability_ids.insert("test.c".to_string(), std::time::Duration::from_millis(15));
819        let executor = ScriptedExecutor {
820            sleep_capability_ids,
821            ..ScriptedExecutor::default()
822        };
823        let concurrency = Arc::clone(&executor.concurrency);
824        let runtime = Runtime::new(diamond_registry(), executor)
825            .with_security_config(RuntimeSecurityConfig::development());
826
827        let limits = ParallelExecutionLimits {
828            max_concurrent_nodes: 1,
829            ..ParallelExecutionLimits::default()
830        };
831        let trace = execute_parallel_proposal(
832            &runtime,
833            &canonical,
834            &schedule,
835            AuthorizationSummary {
836                automatic: true,
837                approval_token_id: None,
838            },
839            "digest",
840            "snapshot-digest",
841            &limits,
842        );
843
844        assert_eq!(trace.terminal_state, ProposalTerminalState::Succeeded);
845        assert_eq!(concurrency.max_seen(), 1);
846        Ok(())
847    }
848
849    #[test]
850    fn stops_advancing_after_a_node_failure_but_lets_the_dispatched_wave_finish()
851    -> Result<(), String> {
852        let canonical = diamond_canonical();
853        let schedule = compute_parallel_schedule(&canonical, &ParallelScheduleLimits::default())
854            .map_err(|e| format!("{e:?}"))?;
855
856        let mut fail_capability_ids = HashSet::new();
857        fail_capability_ids.insert("test.c".to_string());
858        let executor = ScriptedExecutor {
859            fail_capability_ids,
860            ..ScriptedExecutor::default()
861        };
862        let runtime = Runtime::new(diamond_registry(), executor)
863            .with_security_config(RuntimeSecurityConfig::development());
864
865        let trace = execute_parallel_proposal(
866            &runtime,
867            &canonical,
868            &schedule,
869            AuthorizationSummary {
870                automatic: true,
871                approval_token_id: None,
872            },
873            "digest",
874            "snapshot-digest",
875            &ParallelExecutionLimits::default(),
876        );
877
878        assert_eq!(trace.terminal_state, ProposalTerminalState::Failed);
879        let outcome_by_node: StdHashMap<&str, ProposalNodeStatus> = trace
880            .node_outcomes
881            .iter()
882            .map(|o| (o.node_id.as_str(), o.status.clone()))
883            .collect();
884        assert_eq!(outcome_by_node["a"], ProposalNodeStatus::Succeeded);
885        assert_eq!(outcome_by_node["b"], ProposalNodeStatus::Succeeded);
886        assert_eq!(outcome_by_node["c"], ProposalNodeStatus::Failed);
887        assert_eq!(
888            outcome_by_node["d"],
889            ProposalNodeStatus::SkippedAfterEarlierFailure
890        );
891        Ok(())
892    }
893
894    #[test]
895    fn surfaces_an_executor_panic_as_a_failed_outcome_instead_of_dropping_it() -> Result<(), String>
896    {
897        let canonical = diamond_canonical();
898        let schedule = compute_parallel_schedule(&canonical, &ParallelScheduleLimits::default())
899            .map_err(|e| format!("{e:?}"))?;
900
901        let mut panic_capability_ids = HashSet::new();
902        panic_capability_ids.insert("test.c".to_string());
903        let executor = ScriptedExecutor {
904            panic_capability_ids,
905            ..ScriptedExecutor::default()
906        };
907        let runtime = Runtime::new(diamond_registry(), executor)
908            .with_security_config(RuntimeSecurityConfig::development());
909
910        let trace = execute_parallel_proposal(
911            &runtime,
912            &canonical,
913            &schedule,
914            AuthorizationSummary {
915                automatic: true,
916                approval_token_id: None,
917            },
918            "digest",
919            "snapshot-digest",
920            &ParallelExecutionLimits::default(),
921        );
922
923        assert_eq!(trace.terminal_state, ProposalTerminalState::Failed);
924        let c_outcome = trace
925            .node_outcomes
926            .iter()
927            .find(|o| o.node_id == "c")
928            .expect("c must have an outcome, not be silently dropped");
929        assert_eq!(c_outcome.status, ProposalNodeStatus::Failed);
930        assert_eq!(c_outcome.error_code, Some("executor_panicked".to_string()));
931        Ok(())
932    }
933
934    #[test]
935    fn cancels_further_waves_once_the_wall_time_budget_is_exhausted() -> Result<(), String> {
936        let canonical = diamond_canonical();
937        let schedule = compute_parallel_schedule(&canonical, &ParallelScheduleLimits::default())
938            .map_err(|e| format!("{e:?}"))?;
939
940        let mut sleep_capability_ids = StdHashMap::new();
941        sleep_capability_ids.insert("test.a".to_string(), std::time::Duration::from_millis(40));
942        let executor = ScriptedExecutor {
943            sleep_capability_ids,
944            ..ScriptedExecutor::default()
945        };
946        let runtime = Runtime::new(diamond_registry(), executor)
947            .with_security_config(RuntimeSecurityConfig::development());
948
949        let limits = ParallelExecutionLimits {
950            max_wall_time: std::time::Duration::from_millis(10),
951            ..ParallelExecutionLimits::default()
952        };
953        let trace = execute_parallel_proposal(
954            &runtime,
955            &canonical,
956            &schedule,
957            AuthorizationSummary {
958                automatic: true,
959                approval_token_id: None,
960            },
961            "digest",
962            "snapshot-digest",
963            &limits,
964        );
965
966        assert_eq!(trace.terminal_state, ProposalTerminalState::Cancelled);
967        let outcome_by_node: StdHashMap<&str, ProposalNodeStatus> = trace
968            .node_outcomes
969            .iter()
970            .map(|o| (o.node_id.as_str(), o.status.clone()))
971            .collect();
972        assert_eq!(outcome_by_node["a"], ProposalNodeStatus::Succeeded);
973        assert_eq!(
974            outcome_by_node["b"],
975            ProposalNodeStatus::SkippedAfterEarlierFailure
976        );
977        Ok(())
978    }
979
980    #[test]
981    fn cancels_a_wave_whose_assembled_payload_exceeds_the_configured_byte_budget()
982    -> Result<(), String> {
983        let canonical = diamond_canonical();
984        let schedule = compute_parallel_schedule(&canonical, &ParallelScheduleLimits::default())
985            .map_err(|e| format!("{e:?}"))?;
986        let executor = ScriptedExecutor::default();
987        let runtime = Runtime::new(diamond_registry(), executor)
988            .with_security_config(RuntimeSecurityConfig::development());
989
990        let limits = ParallelExecutionLimits {
991            max_wave_payload_bytes: 0,
992            ..ParallelExecutionLimits::default()
993        };
994        let trace = execute_parallel_proposal(
995            &runtime,
996            &canonical,
997            &schedule,
998            AuthorizationSummary {
999                automatic: true,
1000                approval_token_id: None,
1001            },
1002            "digest",
1003            "snapshot-digest",
1004            &limits,
1005        );
1006
1007        assert_eq!(trace.terminal_state, ProposalTerminalState::Cancelled);
1008        Ok(())
1009    }
1010
1011    #[test]
1012    fn execute_parallel_proposal_skips_an_unresolved_node_id_within_a_dispatched_wave()
1013    -> Result<(), String> {
1014        // `ParallelSchedule` has public fields, so a caller could hand-build
1015        // one that disagrees with `canonical` — never produced by
1016        // `compute_parallel_schedule` itself. This proves the executor
1017        // degrades gracefully (silently skips the unresolved id) rather than
1018        // panicking.
1019        let mut proposal = diamond_workflow_proposal();
1020        proposal.nodes.retain(|n| n.node_id == "a");
1021        proposal.edges.clear();
1022        let canonical = canonicalize_proposal(proposal, &ProposalLimits::default())
1023            .map_err(|e| format!("{e:?}"))?;
1024        let schedule = ParallelSchedule {
1025            waves: vec![vec!["a".to_string(), "ghost".to_string()]],
1026        };
1027
1028        let executor = ScriptedExecutor::default();
1029        let runtime = Runtime::new(
1030            registry_with(vec![(
1031                contract("test.a", EffectClass::PureRead),
1032                artifact("digest-a"),
1033            )]),
1034            executor,
1035        )
1036        .with_security_config(RuntimeSecurityConfig::development());
1037
1038        let trace = execute_parallel_proposal(
1039            &runtime,
1040            &canonical,
1041            &schedule,
1042            AuthorizationSummary {
1043                automatic: true,
1044                approval_token_id: None,
1045            },
1046            "digest",
1047            "snapshot-digest",
1048            &ParallelExecutionLimits::default(),
1049        );
1050
1051        assert_eq!(trace.terminal_state, ProposalTerminalState::Succeeded);
1052        assert_eq!(trace.node_outcomes.len(), 1);
1053        assert_eq!(trace.node_outcomes[0].node_id, "a");
1054        Ok(())
1055    }
1056
1057    #[test]
1058    fn execute_parallel_proposal_skips_an_unresolved_node_id_in_a_skipped_wave()
1059    -> Result<(), String> {
1060        let mut proposal = diamond_workflow_proposal();
1061        proposal.nodes.retain(|n| n.node_id == "a");
1062        proposal.edges.clear();
1063        let canonical = canonicalize_proposal(proposal, &ProposalLimits::default())
1064            .map_err(|e| format!("{e:?}"))?;
1065        let schedule = ParallelSchedule {
1066            waves: vec![vec!["a".to_string()], vec!["ghost".to_string()]],
1067        };
1068
1069        let mut fail_capability_ids = HashSet::new();
1070        fail_capability_ids.insert("test.a".to_string());
1071        let executor = ScriptedExecutor {
1072            fail_capability_ids,
1073            ..ScriptedExecutor::default()
1074        };
1075        let runtime = Runtime::new(
1076            registry_with(vec![(
1077                contract("test.a", EffectClass::PureRead),
1078                artifact("digest-a"),
1079            )]),
1080            executor,
1081        )
1082        .with_security_config(RuntimeSecurityConfig::development());
1083
1084        let trace = execute_parallel_proposal(
1085            &runtime,
1086            &canonical,
1087            &schedule,
1088            AuthorizationSummary {
1089                automatic: true,
1090                approval_token_id: None,
1091            },
1092            "digest",
1093            "snapshot-digest",
1094            &ParallelExecutionLimits::default(),
1095        );
1096
1097        assert_eq!(trace.terminal_state, ProposalTerminalState::Failed);
1098        assert_eq!(trace.node_outcomes.len(), 1);
1099        assert_eq!(trace.node_outcomes[0].node_id, "a");
1100        Ok(())
1101    }
1102
1103    #[test]
1104    fn parallel_execution_limits_default_matches_the_documented_defaults() {
1105        let limits = ParallelExecutionLimits::default();
1106        assert_eq!(
1107            limits.max_wall_time,
1108            std::time::Duration::from_millis(DEFAULT_MAX_WALL_TIME_MS)
1109        );
1110        assert_eq!(
1111            limits.max_wave_payload_bytes,
1112            DEFAULT_MAX_WAVE_PAYLOAD_BYTES
1113        );
1114        assert_eq!(
1115            limits.max_concurrent_nodes,
1116            traverse_contracts::DEFAULT_MAX_CONCURRENT_NODES
1117        );
1118    }
1119}