diff --git a/Cargo.lock b/Cargo.lock index f541edc1..6b5c56b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2648,6 +2648,7 @@ dependencies = [ "semver", "serde", "serde_json", + "sha2 0.11.0", ] [[package]] @@ -2676,6 +2677,7 @@ dependencies = [ name = "traverse-mcp" version = "0.9.1" dependencies = [ + "ed25519-dalek", "serde", "serde_json", "traverse-contracts", diff --git a/crates/traverse-contracts/Cargo.toml b/crates/traverse-contracts/Cargo.toml index c16517ea..86292f36 100644 --- a/crates/traverse-contracts/Cargo.toml +++ b/crates/traverse-contracts/Cargo.toml @@ -11,6 +11,7 @@ description = "Contract definitions and validation for the Traverse capability r semver = "1.0.27" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.145" +sha2 = "0.11" [lints] workspace = true diff --git a/crates/traverse-contracts/src/lib.rs b/crates/traverse-contracts/src/lib.rs index b392b16c..3bfc84cf 100644 --- a/crates/traverse-contracts/src/lib.rs +++ b/crates/traverse-contracts/src/lib.rs @@ -5,8 +5,15 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::{BTreeSet, HashSet}; +pub mod proposal; pub mod usage_telemetry; pub mod violations; +pub use proposal::{ + CanonicalProposal, ManifestReference, MappingSource, ProposalEdge, ProposalLimits, + ProposalMapping, ProposalNode, ProposalValidationError, ProposalValidationErrorCode, + ProposalValidationFailure, SnapshotDigests, WorkflowProposal, canonicalize_proposal, + proposal_digest, proposal_snapshot_digest, +}; pub use usage_telemetry::{NoOpUsageTelemetrySink, UsageEvent, UsageEventKind, UsageTelemetrySink}; pub use violations::ViolationRecord; diff --git a/crates/traverse-contracts/src/proposal.rs b/crates/traverse-contracts/src/proposal.rs new file mode 100644 index 00000000..4e4b9955 --- /dev/null +++ b/crates/traverse-contracts/src/proposal.rs @@ -0,0 +1,552 @@ +//! Runtime workflow proposal types, canonicalization, and digesting. +//! +//! Governed by spec `109-runtime-workflow-proposals` (P1) and ADR-0041. A +//! proposal is an untrusted, externally-authored, ephemeral bounded sequential +//! DAG over already-registered capabilities. This module owns the portable +//! parts of the lifecycle that need no manifest or registry access: the wire +//! format, canonical-JSON digesting, and structural validation (acyclic, +//! within configured limits, no dangling/ambiguous references). Cross-checks +//! against a loaded application manifest, capability registry, and risk +//! metadata live in `traverse-runtime`, which already depends on both this +//! crate and `traverse-registry`. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; + +const PROPOSAL_KIND: &str = "workflow_proposal"; +const PROPOSAL_SCHEMA_VERSION: &str = "1.0.0"; +const PROPOSAL_DIGEST_VERSION: &str = "1.0.0"; + +/// A caller-submitted, ephemeral, manifest-bound workflow proposal (spec 109 +/// FR-002). Every field the runtime authorizes or executes against must be +/// explicit here — no inference from schema shape alone. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct WorkflowProposal { + pub kind: String, + pub schema_version: String, + pub proposal_id: String, + /// Tenant/workspace scope this proposal is submitted under. + pub workspace_id: String, + pub app_manifest: ManifestReference, + pub nodes: Vec, + pub edges: Vec, + pub mappings: Vec, + /// Bound to `MappingSource::InitialInput` mappings; the only externally + /// supplied data a proposal may inject into the graph. + pub initial_input: Value, +} + +/// Identifies the exact, already-registered application manifest version a +/// proposal is bounded by (spec 109 FR-002, ADR-0041: "a proposal is +/// constrained by its versioned application manifest"). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ManifestReference { + pub app_id: String, + pub app_version: String, + pub manifest_digest: String, +} + +/// One DAG node: an exact, pinned capability artifact (spec 109 FR-007a: +/// "exact resolved capability/artifact versions and digests"). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProposalNode { + pub node_id: String, + pub capability_id: String, + pub capability_version: String, + pub artifact_digest: String, +} + +/// An explicit control-flow dependency: `to_node_id` may not start before +/// `from_node_id` reaches a terminal status. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProposalEdge { + pub from_node_id: String, + pub to_node_id: String, +} + +/// An explicit source-path to target-path data mapping (spec 109 FR-002, +/// FR-011). Every field a node's input receives must arrive through one of +/// these — a node never sees another node's full output implicitly. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProposalMapping { + pub source: MappingSource, + /// JSON Pointer (RFC 6901) into the source's output (or `initial_input`). + pub source_path: String, + pub target_node_id: String, + /// JSON Pointer (RFC 6901) into the target node's input. + pub target_path: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "kind")] +pub enum MappingSource { + InitialInput, + Node { node_id: String }, +} + +/// Configured structural limits a proposal must fall within (spec 109 +/// FR-007). Values are runtime/host configuration, not caller-supplied. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProposalLimits { + pub max_nodes: usize, + pub max_edges: usize, + pub max_mappings: usize, + pub max_initial_input_bytes: usize, +} + +pub const DEFAULT_MAX_PROPOSAL_NODES: usize = 32; +pub const DEFAULT_MAX_PROPOSAL_EDGES: usize = 64; +pub const DEFAULT_MAX_PROPOSAL_MAPPINGS: usize = 128; +pub const DEFAULT_MAX_INITIAL_INPUT_BYTES: usize = 262_144; + +impl Default for ProposalLimits { + fn default() -> Self { + Self { + max_nodes: DEFAULT_MAX_PROPOSAL_NODES, + max_edges: DEFAULT_MAX_PROPOSAL_EDGES, + max_mappings: DEFAULT_MAX_PROPOSAL_MAPPINGS, + max_initial_input_bytes: DEFAULT_MAX_INITIAL_INPUT_BYTES, + } + } +} + +/// A structurally validated proposal with a deterministic execution order +/// (spec 109 FR-007a: "deterministic ready-node tie breaking"). +#[derive(Debug, Clone, PartialEq)] +pub struct CanonicalProposal { + pub proposal: WorkflowProposal, + /// Node ids in deterministic topological execution order. + pub execution_order: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ProposalValidationFailure { + pub errors: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ProposalValidationError { + pub code: ProposalValidationErrorCode, + pub message: String, + pub path: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ProposalValidationErrorCode { + InvalidLiteral, + MissingRequiredField, + NodeLimitExceeded, + EdgeLimitExceeded, + MappingLimitExceeded, + PayloadLimitExceeded, + DuplicateNodeId, + UnknownEdgeEndpoint, + SelfLoopEdge, + DuplicateEdge, + CyclicGraph, + UnknownMappingEndpoint, + AmbiguousMultiWriterTarget, + MissingDependencyEdgeForMapping, +} + +/// Structurally validates a proposal and computes its deterministic +/// execution order. Performs no manifest, registry, or risk-metadata +/// cross-checks — those require host state and live in `traverse-runtime`. +/// +/// # Errors +/// +/// Returns [`ProposalValidationFailure`] when the proposal is malformed, over +/// a configured limit, cyclic, or contains a dangling/ambiguous reference. +#[allow(clippy::too_many_lines)] +pub fn canonicalize_proposal( + proposal: WorkflowProposal, + limits: &ProposalLimits, +) -> Result { + let mut errors = Vec::new(); + + if proposal.kind != PROPOSAL_KIND { + errors.push(error( + ProposalValidationErrorCode::InvalidLiteral, + "$.kind", + "kind must equal workflow_proposal", + )); + } + if proposal.schema_version != PROPOSAL_SCHEMA_VERSION { + errors.push(error( + ProposalValidationErrorCode::InvalidLiteral, + "$.schema_version", + "schema_version must equal 1.0.0", + )); + } + validate_non_empty(&proposal.proposal_id, "$.proposal_id", &mut errors); + validate_non_empty(&proposal.workspace_id, "$.workspace_id", &mut errors); + + if proposal.nodes.len() > limits.max_nodes { + errors.push(error( + ProposalValidationErrorCode::NodeLimitExceeded, + "$.nodes", + &format!( + "proposal declares {} nodes, exceeding the configured limit of {}", + proposal.nodes.len(), + limits.max_nodes + ), + )); + } + if proposal.edges.len() > limits.max_edges { + errors.push(error( + ProposalValidationErrorCode::EdgeLimitExceeded, + "$.edges", + &format!( + "proposal declares {} edges, exceeding the configured limit of {}", + proposal.edges.len(), + limits.max_edges + ), + )); + } + if proposal.mappings.len() > limits.max_mappings { + errors.push(error( + ProposalValidationErrorCode::MappingLimitExceeded, + "$.mappings", + &format!( + "proposal declares {} mappings, exceeding the configured limit of {}", + proposal.mappings.len(), + limits.max_mappings + ), + )); + } + let initial_input_bytes = + serde_json::to_vec(&proposal.initial_input).map_or(usize::MAX, |bytes| bytes.len()); + if initial_input_bytes > limits.max_initial_input_bytes { + errors.push(error( + ProposalValidationErrorCode::PayloadLimitExceeded, + "$.initial_input", + &format!( + "initial_input is {initial_input_bytes} bytes, exceeding the configured limit of \ + {} bytes", + limits.max_initial_input_bytes + ), + )); + } + + let mut node_ids: BTreeSet = BTreeSet::new(); + for (index, node) in proposal.nodes.iter().enumerate() { + let path = format!("$.nodes[{index}].node_id"); + validate_non_empty(&node.node_id, &path, &mut errors); + validate_non_empty( + &node.capability_id, + &format!("$.nodes[{index}].capability_id"), + &mut errors, + ); + validate_non_empty( + &node.capability_version, + &format!("$.nodes[{index}].capability_version"), + &mut errors, + ); + validate_non_empty( + &node.artifact_digest, + &format!("$.nodes[{index}].artifact_digest"), + &mut errors, + ); + if !node_ids.insert(node.node_id.clone()) { + errors.push(error( + ProposalValidationErrorCode::DuplicateNodeId, + &path, + &format!("node_id '{}' is declared more than once", node.node_id), + )); + } + } + + let mut adjacency: BTreeMap> = BTreeMap::new(); + let mut in_degree: BTreeMap = + node_ids.iter().map(|id| (id.clone(), 0)).collect(); + let mut declared_edges: BTreeSet<(String, String)> = BTreeSet::new(); + for (index, edge) in proposal.edges.iter().enumerate() { + let path = format!("$.edges[{index}]"); + if edge.from_node_id == edge.to_node_id { + errors.push(error( + ProposalValidationErrorCode::SelfLoopEdge, + &path, + &format!("edge from '{}' to itself is not allowed", edge.from_node_id), + )); + continue; + } + if !node_ids.contains(&edge.from_node_id) { + errors.push(error( + ProposalValidationErrorCode::UnknownEdgeEndpoint, + &format!("{path}.from_node_id"), + &format!("edge references unknown node_id '{}'", edge.from_node_id), + )); + continue; + } + if !node_ids.contains(&edge.to_node_id) { + errors.push(error( + ProposalValidationErrorCode::UnknownEdgeEndpoint, + &format!("{path}.to_node_id"), + &format!("edge references unknown node_id '{}'", edge.to_node_id), + )); + continue; + } + let key = (edge.from_node_id.clone(), edge.to_node_id.clone()); + if !declared_edges.insert(key) { + errors.push(error( + ProposalValidationErrorCode::DuplicateEdge, + &path, + &format!( + "edge '{}' -> '{}' is declared more than once", + edge.from_node_id, edge.to_node_id + ), + )); + continue; + } + adjacency + .entry(edge.from_node_id.clone()) + .or_default() + .insert(edge.to_node_id.clone()); + *in_degree.entry(edge.to_node_id.clone()).or_insert(0) += 1; + } + + let mut writer_targets: BTreeMap<(String, String), usize> = BTreeMap::new(); + for (index, mapping) in proposal.mappings.iter().enumerate() { + let path = format!("$.mappings[{index}]"); + validate_non_empty( + &mapping.source_path, + &format!("{path}.source_path"), + &mut errors, + ); + validate_non_empty( + &mapping.target_path, + &format!("{path}.target_path"), + &mut errors, + ); + if !node_ids.contains(&mapping.target_node_id) { + errors.push(error( + ProposalValidationErrorCode::UnknownMappingEndpoint, + &format!("{path}.target_node_id"), + &format!( + "mapping targets unknown node_id '{}'", + mapping.target_node_id + ), + )); + continue; + } + if let MappingSource::Node { node_id } = &mapping.source { + if !node_ids.contains(node_id) { + errors.push(error( + ProposalValidationErrorCode::UnknownMappingEndpoint, + &format!("{path}.source"), + &format!("mapping sources unknown node_id '{node_id}'"), + )); + continue; + } + if !declared_edges.contains(&(node_id.clone(), mapping.target_node_id.clone())) { + errors.push(error( + ProposalValidationErrorCode::MissingDependencyEdgeForMapping, + &path, + &format!( + "mapping from '{node_id}' to '{}' has no corresponding declared edge", + mapping.target_node_id + ), + )); + continue; + } + } + let writer_key = (mapping.target_node_id.clone(), mapping.target_path.clone()); + *writer_targets.entry(writer_key).or_insert(0) += 1; + } + for ((target_node_id, target_path), count) in &writer_targets { + if *count > 1 { + errors.push(error( + ProposalValidationErrorCode::AmbiguousMultiWriterTarget, + &format!( + "$.mappings[?target_node_id={target_node_id}][?target_path={target_path}]" + ), + &format!( + "target path '{target_path}' on node '{target_node_id}' is written by {count} \ + mappings; a target path may have at most one writer" + ), + )); + } + } + + if !errors.is_empty() { + return Err(ProposalValidationFailure { errors }); + } + + let Ok(execution_order) = topological_order(&node_ids, &adjacency, &in_degree) else { + return Err(ProposalValidationFailure { + errors: vec![error( + ProposalValidationErrorCode::CyclicGraph, + "$.edges", + "proposal graph contains a cycle; P1 requires an acyclic graph", + )], + }); + }; + + Ok(CanonicalProposal { + proposal, + execution_order, + }) +} + +/// Kahn's algorithm with lexicographic tie-breaking among ready nodes, +/// satisfying spec 109 FR-007a's determinism requirement. +fn topological_order( + node_ids: &BTreeSet, + adjacency: &BTreeMap>, + in_degree: &BTreeMap, +) -> Result, ()> { + let mut remaining_in_degree = in_degree.clone(); + let mut ready: BTreeSet = node_ids + .iter() + .filter(|id| remaining_in_degree.get(*id).copied().unwrap_or(0) == 0) + .cloned() + .collect(); + let mut order = Vec::with_capacity(node_ids.len()); + + while let Some(next) = ready.iter().next().cloned() { + ready.remove(&next); + order.push(next.clone()); + let Some(successors) = adjacency.get(&next) else { + continue; + }; + for successor in successors { + // Every successor was validated to be a declared node_id before + // this function runs, and `remaining_in_degree` is seeded with + // every declared node_id — this entry always already exists. + let degree = remaining_in_degree.entry(successor.clone()).or_insert(0); + *degree -= 1; + if *degree == 0 { + ready.insert(successor.clone()); + } + } + } + + if order.len() == node_ids.len() { + Ok(order) + } else { + Err(()) + } +} + +/// Deterministic, independently-reproducible digest of a proposal's canonical +/// JSON form (spec 109 FR-003, FR-007a). Uses recursively key-sorted JSON +/// (not Rust `Debug` formatting) hashed with SHA-256, so an external proposer +/// can recompute the identical digest from the same JSON payload. +#[must_use] +pub fn proposal_digest(proposal: &WorkflowProposal) -> String { + let value = serde_json::to_value(proposal).unwrap_or(Value::Null); + digest_json_value(&value) +} + +/// Binds a proposal digest to the pinned snapshot digests it was validated +/// against (spec 109 FR-003: "bind its digest to pinned manifest, registry, +/// binding, policy, and budget snapshots"). This is the digest an approval +/// token is scoped to (ADR-0041, FR-006a) — it changes if any governing +/// snapshot changes even when the proposal JSON is byte-identical. +#[must_use] +pub fn proposal_snapshot_digest(proposal_digest: &str, snapshots: &SnapshotDigests) -> String { + let value = serde_json::json!({ + "proposal_digest": proposal_digest, + "manifest_digest": snapshots.manifest_digest, + "registry_digest": snapshots.registry_digest, + "binding_digest": snapshots.binding_digest, + "policy_digest": snapshots.policy_digest, + "budget_digest": snapshots.budget_digest, + }); + digest_json_value(&value) +} + +/// The pinned snapshot digests a proposal's authorization is bound to (spec +/// 109 FR-003). Each field is a digest computed by the caller (typically +/// `traverse-runtime`) over the corresponding live host state at validation +/// time — this type only carries them, it does not compute them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SnapshotDigests { + pub manifest_digest: String, + pub registry_digest: String, + pub binding_digest: String, + pub policy_digest: String, + pub budget_digest: String, +} + +fn digest_json_value(value: &Value) -> String { + let canonical = canonical_json_string(value); + let mut hasher = Sha256::new(); + hasher.update(canonical.as_bytes()); + let digest = hasher.finalize(); + format!("{PROPOSAL_DIGEST_VERSION}:sha256:{}", hex_encode(&digest)) +} + +fn hex_encode(bytes: &[u8]) -> String { + use std::fmt::Write as _; + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + let _ = write!(out, "{byte:02x}"); + } + out +} + +/// Serializes a JSON value with recursively sorted object keys and no +/// insignificant whitespace, so semantically identical JSON always produces +/// byte-identical output regardless of source field order. +fn canonical_json_string(value: &Value) -> String { + let mut out = String::new(); + write_canonical(value, &mut out); + out +} + +fn write_canonical(value: &Value, out: &mut String) { + match value { + Value::Null | Value::Bool(_) | Value::Number(_) => { + out.push_str(&value.to_string()); + } + Value::String(s) => { + out.push_str(&serde_json::to_string(s).unwrap_or_default()); + } + Value::Array(items) => { + out.push('['); + for (index, item) in items.iter().enumerate() { + if index > 0 { + out.push(','); + } + write_canonical(item, out); + } + out.push(']'); + } + Value::Object(map) => { + out.push('{'); + let mut keys: Vec<&String> = map.keys().collect(); + keys.sort(); + for (index, key) in keys.iter().enumerate() { + if index > 0 { + out.push(','); + } + out.push_str(&serde_json::to_string(key).unwrap_or_default()); + out.push(':'); + write_canonical(&map[*key], out); + } + out.push('}'); + } + } +} + +fn validate_non_empty(value: &str, path: &str, errors: &mut Vec) { + if value.trim().is_empty() { + errors.push(error( + ProposalValidationErrorCode::MissingRequiredField, + path, + "value must be non-empty", + )); + } +} + +fn error(code: ProposalValidationErrorCode, path: &str, message: &str) -> ProposalValidationError { + ProposalValidationError { + code, + message: message.to_string(), + path: path.to_string(), + } +} diff --git a/crates/traverse-contracts/tests/proposal.rs b/crates/traverse-contracts/tests/proposal.rs new file mode 100644 index 00000000..348b8724 --- /dev/null +++ b/crates/traverse-contracts/tests/proposal.rs @@ -0,0 +1,533 @@ +use traverse_contracts::{ + CanonicalProposal, ManifestReference, MappingSource, ProposalEdge, ProposalLimits, + ProposalMapping, ProposalNode, ProposalValidationErrorCode, ProposalValidationFailure, + SnapshotDigests, WorkflowProposal, canonicalize_proposal, proposal_digest, + proposal_snapshot_digest, +}; + +fn expect_failure( + result: Result, +) -> Result { + match result { + Ok(_) => Err("validation unexpectedly succeeded".to_string()), + Err(failure) => Ok(failure), + } +} + +fn manifest_reference() -> ManifestReference { + ManifestReference { + app_id: "expedition-planner".to_string(), + app_version: "1.0.0".to_string(), + manifest_digest: "sha256:manifest-digest".to_string(), + } +} + +fn node(node_id: &str, capability_id: &str) -> ProposalNode { + ProposalNode { + node_id: node_id.to_string(), + capability_id: capability_id.to_string(), + capability_version: "1.0.0".to_string(), + artifact_digest: format!("sha256:{capability_id}-digest"), + } +} + +fn linear_proposal() -> WorkflowProposal { + WorkflowProposal { + kind: "workflow_proposal".to_string(), + schema_version: "1.0.0".to_string(), + proposal_id: "proposal-001".to_string(), + workspace_id: "workspace-001".to_string(), + app_manifest: manifest_reference(), + nodes: vec![ + node("a", "content.comments.create-comment-draft"), + node("b", "content.comments.publish-comment"), + ], + edges: vec![ProposalEdge { + from_node_id: "a".to_string(), + to_node_id: "b".to_string(), + }], + mappings: vec![ProposalMapping { + source: MappingSource::Node { + node_id: "a".to_string(), + }, + source_path: "/draft_id".to_string(), + target_node_id: "b".to_string(), + target_path: "/draft_id".to_string(), + }], + initial_input: serde_json::json!({"comment_text": "hello", "resource_id": "r1"}), + } +} + +#[test] +fn canonicalizes_a_valid_linear_proposal_in_dependency_order() -> Result<(), String> { + let canonical = canonicalize_proposal(linear_proposal(), &ProposalLimits::default()) + .map_err(|e| format!("{e:?}"))?; + assert_eq!( + canonical.execution_order, + vec!["a".to_string(), "b".to_string()] + ); + Ok(()) +} + +#[test] +fn diamond_graph_uses_lexicographic_tie_break_among_ready_nodes() -> Result<(), String> { + // b and c both depend only on a; neither depends on the other. The + // deterministic tie-break must always pick b before c. + let mut proposal = linear_proposal(); + proposal.nodes = vec![ + node("a", "content.comments.create-comment-draft"), + node("c", "content.comments.publish-comment"), + node("b", "content.comments.publish-comment"), + node("d", "content.comments.publish-comment"), + ]; + proposal.edges = vec![ + ProposalEdge { + from_node_id: "a".to_string(), + to_node_id: "b".to_string(), + }, + ProposalEdge { + from_node_id: "a".to_string(), + to_node_id: "c".to_string(), + }, + ProposalEdge { + from_node_id: "b".to_string(), + to_node_id: "d".to_string(), + }, + ProposalEdge { + from_node_id: "c".to_string(), + to_node_id: "d".to_string(), + }, + ]; + proposal.mappings = Vec::new(); + + let canonical = canonicalize_proposal(proposal, &ProposalLimits::default()) + .map_err(|e| format!("{e:?}"))?; + assert_eq!( + canonical.execution_order, + vec![ + "a".to_string(), + "b".to_string(), + "c".to_string(), + "d".to_string() + ] + ); + Ok(()) +} + +#[test] +fn wide_fan_out_and_fan_in_graph_orders_deterministically() -> Result<(), String> { + // a fans out to three independent successors (b, c, e), each of which + // feeds the same terminal node d — exercising both the "in-degree drops + // to exactly zero" and "in-degree merely decreases" branches multiple + // times across a single node's outgoing edges. + let mut proposal = linear_proposal(); + proposal.nodes = vec![ + node("a", "content.comments.create-comment-draft"), + node("b", "content.comments.publish-comment"), + node("c", "content.comments.publish-comment"), + node("e", "content.comments.publish-comment"), + node("d", "content.comments.publish-comment"), + ]; + proposal.edges = vec![ + ProposalEdge { + from_node_id: "a".to_string(), + to_node_id: "b".to_string(), + }, + ProposalEdge { + from_node_id: "a".to_string(), + to_node_id: "c".to_string(), + }, + ProposalEdge { + from_node_id: "a".to_string(), + to_node_id: "e".to_string(), + }, + ProposalEdge { + from_node_id: "b".to_string(), + to_node_id: "d".to_string(), + }, + ProposalEdge { + from_node_id: "c".to_string(), + to_node_id: "d".to_string(), + }, + ProposalEdge { + from_node_id: "e".to_string(), + to_node_id: "d".to_string(), + }, + ]; + proposal.mappings = Vec::new(); + + let canonical = canonicalize_proposal(proposal, &ProposalLimits::default()) + .map_err(|e| format!("{e:?}"))?; + assert_eq!( + canonical.execution_order, + vec![ + "a".to_string(), + "b".to_string(), + "c".to_string(), + "e".to_string(), + "d".to_string(), + ] + ); + Ok(()) +} + +#[test] +fn rejects_a_cyclic_graph() -> Result<(), String> { + let mut proposal = linear_proposal(); + proposal.edges.push(ProposalEdge { + from_node_id: "b".to_string(), + to_node_id: "a".to_string(), + }); + proposal.mappings = Vec::new(); + + let failure = expect_failure(canonicalize_proposal(proposal, &ProposalLimits::default()))?; + assert!( + failure + .errors + .iter() + .any(|e| e.code == ProposalValidationErrorCode::CyclicGraph) + ); + Ok(()) +} + +#[test] +fn rejects_a_self_loop_edge() -> Result<(), String> { + let mut proposal = linear_proposal(); + proposal.mappings = Vec::new(); + proposal.edges = vec![ProposalEdge { + from_node_id: "a".to_string(), + to_node_id: "a".to_string(), + }]; + + let failure = expect_failure(canonicalize_proposal(proposal, &ProposalLimits::default()))?; + assert!( + failure + .errors + .iter() + .any(|e| e.code == ProposalValidationErrorCode::SelfLoopEdge) + ); + Ok(()) +} + +#[test] +fn rejects_duplicate_node_ids() -> Result<(), String> { + let mut proposal = linear_proposal(); + proposal + .nodes + .push(node("a", "content.comments.publish-comment")); + + let failure = expect_failure(canonicalize_proposal(proposal, &ProposalLimits::default()))?; + assert!( + failure + .errors + .iter() + .any(|e| e.code == ProposalValidationErrorCode::DuplicateNodeId) + ); + Ok(()) +} + +#[test] +fn rejects_an_edge_to_an_unknown_node() -> Result<(), String> { + let mut proposal = linear_proposal(); + proposal.edges.push(ProposalEdge { + from_node_id: "a".to_string(), + to_node_id: "missing".to_string(), + }); + + let failure = expect_failure(canonicalize_proposal(proposal, &ProposalLimits::default()))?; + assert!( + failure + .errors + .iter() + .any(|e| e.code == ProposalValidationErrorCode::UnknownEdgeEndpoint) + ); + Ok(()) +} + +#[test] +fn rejects_a_duplicate_edge() -> Result<(), String> { + let mut proposal = linear_proposal(); + proposal.edges.push(ProposalEdge { + from_node_id: "a".to_string(), + to_node_id: "b".to_string(), + }); + + let failure = expect_failure(canonicalize_proposal(proposal, &ProposalLimits::default()))?; + assert!( + failure + .errors + .iter() + .any(|e| e.code == ProposalValidationErrorCode::DuplicateEdge) + ); + Ok(()) +} + +#[test] +fn rejects_a_mapping_to_an_unknown_target_node() -> Result<(), String> { + let mut proposal = linear_proposal(); + proposal.mappings[0].target_node_id = "missing".to_string(); + + let failure = expect_failure(canonicalize_proposal(proposal, &ProposalLimits::default()))?; + assert!( + failure + .errors + .iter() + .any(|e| e.code == ProposalValidationErrorCode::UnknownMappingEndpoint) + ); + Ok(()) +} + +#[test] +fn rejects_a_mapping_from_an_unknown_source_node() -> Result<(), String> { + let mut proposal = linear_proposal(); + proposal.mappings[0].source = MappingSource::Node { + node_id: "missing".to_string(), + }; + + let failure = expect_failure(canonicalize_proposal(proposal, &ProposalLimits::default()))?; + assert!( + failure + .errors + .iter() + .any(|e| e.code == ProposalValidationErrorCode::UnknownMappingEndpoint) + ); + Ok(()) +} + +#[test] +fn rejects_a_mapping_with_no_corresponding_declared_edge() -> Result<(), String> { + let mut proposal = linear_proposal(); + proposal.edges.clear(); + + let failure = expect_failure(canonicalize_proposal(proposal, &ProposalLimits::default()))?; + assert!( + failure + .errors + .iter() + .any(|e| e.code == ProposalValidationErrorCode::MissingDependencyEdgeForMapping) + ); + Ok(()) +} + +#[test] +fn accepts_a_mapping_from_initial_input_with_no_edge_required() -> Result<(), String> { + let mut proposal = linear_proposal(); + proposal.mappings = vec![ProposalMapping { + source: MappingSource::InitialInput, + source_path: "/comment_text".to_string(), + target_node_id: "a".to_string(), + target_path: "/comment_text".to_string(), + }]; + proposal.edges.clear(); + + canonicalize_proposal(proposal, &ProposalLimits::default()).map_err(|e| format!("{e:?}"))?; + Ok(()) +} + +#[test] +fn rejects_an_ambiguous_multi_writer_target_path() -> Result<(), String> { + let mut proposal = linear_proposal(); + proposal.mappings.push(ProposalMapping { + source: MappingSource::InitialInput, + source_path: "/comment_text".to_string(), + target_node_id: "b".to_string(), + target_path: "/draft_id".to_string(), + }); + + let failure = expect_failure(canonicalize_proposal(proposal, &ProposalLimits::default()))?; + assert!( + failure + .errors + .iter() + .any(|e| e.code == ProposalValidationErrorCode::AmbiguousMultiWriterTarget) + ); + Ok(()) +} + +#[test] +fn rejects_a_proposal_over_the_configured_node_limit() -> Result<(), String> { + let proposal = linear_proposal(); + let limits = ProposalLimits { + max_nodes: 1, + ..ProposalLimits::default() + }; + + let failure = expect_failure(canonicalize_proposal(proposal, &limits))?; + assert!( + failure + .errors + .iter() + .any(|e| e.code == ProposalValidationErrorCode::NodeLimitExceeded) + ); + Ok(()) +} + +#[test] +fn rejects_a_proposal_over_the_configured_edge_limit() -> Result<(), String> { + let proposal = linear_proposal(); + let limits = ProposalLimits { + max_edges: 0, + ..ProposalLimits::default() + }; + + let failure = expect_failure(canonicalize_proposal(proposal, &limits))?; + assert!( + failure + .errors + .iter() + .any(|e| e.code == ProposalValidationErrorCode::EdgeLimitExceeded) + ); + Ok(()) +} + +#[test] +fn rejects_a_proposal_over_the_configured_mapping_limit() -> Result<(), String> { + let proposal = linear_proposal(); + let limits = ProposalLimits { + max_mappings: 0, + ..ProposalLimits::default() + }; + + let failure = expect_failure(canonicalize_proposal(proposal, &limits))?; + assert!( + failure + .errors + .iter() + .any(|e| e.code == ProposalValidationErrorCode::MappingLimitExceeded) + ); + Ok(()) +} + +#[test] +fn rejects_an_edge_from_an_unknown_node() -> Result<(), String> { + let mut proposal = linear_proposal(); + proposal.edges.push(ProposalEdge { + from_node_id: "missing".to_string(), + to_node_id: "b".to_string(), + }); + + let failure = expect_failure(canonicalize_proposal(proposal, &ProposalLimits::default()))?; + assert!( + failure + .errors + .iter() + .any(|e| e.code == ProposalValidationErrorCode::UnknownEdgeEndpoint) + ); + Ok(()) +} + +#[test] +fn rejects_empty_required_fields() -> Result<(), String> { + let mut proposal = linear_proposal(); + proposal.proposal_id = String::new(); + proposal.workspace_id = String::new(); + proposal.nodes[0].node_id = String::new(); + proposal.nodes[0].capability_id = String::new(); + + let failure = expect_failure(canonicalize_proposal(proposal, &ProposalLimits::default()))?; + assert!( + failure + .errors + .iter() + .filter(|e| e.code == ProposalValidationErrorCode::MissingRequiredField) + .count() + >= 4 + ); + Ok(()) +} + +#[test] +fn proposal_digest_is_stable_for_non_string_initial_input_values() { + let mut proposal = linear_proposal(); + proposal.initial_input = serde_json::json!({ + "count": 42, + "enabled": true, + "note": serde_json::Value::Null, + "nested": [1, false, null] + }); + + let digest_one = proposal_digest(&proposal); + let digest_two = proposal_digest(&proposal); + assert_eq!(digest_one, digest_two); +} + +#[test] +fn rejects_a_proposal_over_the_configured_initial_input_byte_limit() -> Result<(), String> { + let proposal = linear_proposal(); + let limits = ProposalLimits { + max_initial_input_bytes: 4, + ..ProposalLimits::default() + }; + + let failure = expect_failure(canonicalize_proposal(proposal, &limits))?; + assert!( + failure + .errors + .iter() + .any(|e| e.code == ProposalValidationErrorCode::PayloadLimitExceeded) + ); + Ok(()) +} + +#[test] +fn rejects_wrong_kind_and_schema_version() -> Result<(), String> { + let mut proposal = linear_proposal(); + proposal.kind = "wrong".to_string(); + proposal.schema_version = "9.9.9".to_string(); + + let failure = expect_failure(canonicalize_proposal(proposal, &ProposalLimits::default()))?; + let codes: Vec<_> = failure.errors.iter().map(|e| e.code).collect(); + assert_eq!( + codes + .iter() + .filter(|c| **c == ProposalValidationErrorCode::InvalidLiteral) + .count(), + 2 + ); + Ok(()) +} + +#[test] +fn proposal_digest_is_stable_and_field_order_independent() -> Result<(), String> { + let proposal = linear_proposal(); + let digest_one = proposal_digest(&proposal); + let digest_two = proposal_digest(&proposal); + assert_eq!(digest_one, digest_two); + + // Round-trip through JSON with reordered object keys must not change the digest. + let value = serde_json::to_value(&proposal).map_err(|e| e.to_string())?; + let json_text = serde_json::to_string(&value).map_err(|e| e.to_string())?; + let reparsed: WorkflowProposal = serde_json::from_str(&json_text).map_err(|e| e.to_string())?; + assert_eq!(proposal_digest(&reparsed), digest_one); + Ok(()) +} + +#[test] +fn proposal_digest_changes_when_content_changes() { + let mut proposal = linear_proposal(); + let original = proposal_digest(&proposal); + proposal.initial_input = serde_json::json!({"comment_text": "different", "resource_id": "r1"}); + assert_ne!(proposal_digest(&proposal), original); +} + +fn snapshots() -> SnapshotDigests { + SnapshotDigests { + manifest_digest: "manifest-1".to_string(), + registry_digest: "registry-1".to_string(), + binding_digest: "binding-1".to_string(), + policy_digest: "policy-1".to_string(), + budget_digest: "budget-1".to_string(), + } +} + +#[test] +fn snapshot_digest_changes_when_any_pinned_snapshot_changes_even_if_proposal_is_identical() { + let digest = proposal_digest(&linear_proposal()); + let base = proposal_snapshot_digest(&digest, &snapshots()); + + let mut changed = snapshots(); + changed.policy_digest = "policy-2".to_string(); + let with_changed_policy = proposal_snapshot_digest(&digest, &changed); + + assert_ne!(base, with_changed_policy); +} diff --git a/crates/traverse-mcp/Cargo.toml b/crates/traverse-mcp/Cargo.toml index c8ba7533..80f30c32 100644 --- a/crates/traverse-mcp/Cargo.toml +++ b/crates/traverse-mcp/Cargo.toml @@ -12,6 +12,7 @@ name = "traverse-mcp" path = "src/main.rs" [dependencies] +ed25519-dalek = "3.0.0" serde = { version = "1", features = ["derive"] } serde_json = "1" traverse-contracts = { workspace = true } diff --git a/crates/traverse-mcp/src/tools/mod.rs b/crates/traverse-mcp/src/tools/mod.rs index 76dac5ae..5d7cb2ca 100644 --- a/crates/traverse-mcp/src/tools/mod.rs +++ b/crates/traverse-mcp/src/tools/mod.rs @@ -4,4 +4,5 @@ pub mod capabilities; pub mod events; +pub mod proposals; pub mod traces; diff --git a/crates/traverse-mcp/src/tools/proposals.rs b/crates/traverse-mcp/src/tools/proposals.rs new file mode 100644 index 00000000..1ad8103e --- /dev/null +++ b/crates/traverse-mcp/src/tools/proposals.rs @@ -0,0 +1,428 @@ +//! MCP tool surfaces for the runtime workflow proposal lifecycle. +//! +//! Governed by spec `109-runtime-workflow-proposals`. Mirrors the +//! `tools::capabilities` pattern: plain, fully-tested Rust functions form the +//! public MCP surface (spec 015's precedent), independent of the separate +//! `stdio_server.rs` reference host transport. + +use ed25519_dalek::VerifyingKey; +use serde::Serialize; +use serde_json::Value; +use std::collections::HashMap; + +use traverse_contracts::{ + ProposalLimits, ProposalValidationError as StructuralError, SnapshotDigests, WorkflowProposal, + canonicalize_proposal, proposal_digest, proposal_snapshot_digest, +}; +use traverse_registry::{ApplicationBundleManifest, CapabilityRegistry}; +use traverse_runtime::proposal::{ + ApprovalTokenStore, ApprovalTokenVerificationContext, AuthorizationDecision, + AuthorizationSummary, ProposalCrossValidationError as CrossError, ProposalTrace, QuotaLimits, + QuotaTracker, execute_proposal, proposal_is_automatic_eligible, + validate_proposal_against_host_state, verify_approval_token, +}; +use traverse_runtime::{LocalExecutor, Runtime}; + +use crate::{McpError, McpErrorCode}; + +/// One stable, machine-readable, secret-free denial (spec 109 FR-010). +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ProposalDenial { + pub code: String, + pub path: String, + pub message: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ProposalValidationResponse { + pub proposal_id: String, + pub proposal_digest: String, + pub valid: bool, + pub errors: Vec, +} + +/// Parses, canonicalizes, and cross-validates a proposal against the loaded +/// manifest and registry (spec 109 FR-001 validation feedback / compatibility +/// inspection). A structurally or semantically invalid proposal is a normal, +/// structured response, never an [`McpError`] — matching FR-010's stable +/// denial-code contract. +/// +/// # Errors +/// +/// Returns [`McpError`] only when `proposal_json` is not valid JSON or does +/// not deserialize into the proposal wire shape at all. +pub fn validate_proposal( + proposal_json: &str, + manifest: &ApplicationBundleManifest, + registry: &CapabilityRegistry, + limits: &ProposalLimits, +) -> Result { + let (proposal, proposal_id, digest) = parse_and_digest(proposal_json)?; + + let canonical = match canonicalize_proposal(proposal, limits) { + Ok(canonical) => canonical, + Err(failure) => { + return Ok(ProposalValidationResponse { + proposal_id, + proposal_digest: digest, + valid: false, + errors: failure.errors.into_iter().map(structural_denial).collect(), + }); + } + }; + + match validate_proposal_against_host_state(&canonical, manifest, registry) { + Ok(_resolved) => Ok(ProposalValidationResponse { + proposal_id, + proposal_digest: digest, + valid: true, + errors: Vec::new(), + }), + Err(failure) => Ok(ProposalValidationResponse { + proposal_id, + proposal_digest: digest, + valid: false, + errors: failure.errors.into_iter().map(cross_denial).collect(), + }), + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct ProposalSubmissionResponse { + pub proposal_id: String, + pub proposal_digest: String, + pub snapshot_digest: String, + pub valid: bool, + pub errors: Vec, + pub automatic_eligible: bool, +} + +/// Submits a proposal: validates it, then binds its digest to the pinned +/// governing snapshots (spec 109 FR-003) and decides automatic eligibility +/// (FR-006). P1 has no server-side proposal catalog — submission does not +/// persist anything; the caller re-presents the same JSON to `execute`. +/// +/// # Errors +/// +/// Returns [`McpError`] only when `proposal_json` is not valid JSON. +pub fn submit_proposal( + proposal_json: &str, + manifest: &ApplicationBundleManifest, + registry: &CapabilityRegistry, + limits: &ProposalLimits, + snapshots: &SnapshotDigests, +) -> Result { + let (proposal, proposal_id, digest) = parse_and_digest(proposal_json)?; + let snapshot_digest = proposal_snapshot_digest(&digest, snapshots); + + let canonical = match canonicalize_proposal(proposal, limits) { + Ok(canonical) => canonical, + Err(failure) => { + return Ok(ProposalSubmissionResponse { + proposal_id, + proposal_digest: digest, + snapshot_digest, + valid: false, + errors: failure.errors.into_iter().map(structural_denial).collect(), + automatic_eligible: false, + }); + } + }; + + match validate_proposal_against_host_state(&canonical, manifest, registry) { + Ok(resolved) => Ok(ProposalSubmissionResponse { + proposal_id, + proposal_digest: digest, + snapshot_digest, + valid: true, + errors: Vec::new(), + automatic_eligible: proposal_is_automatic_eligible(&resolved), + }), + Err(failure) => Ok(ProposalSubmissionResponse { + proposal_id, + proposal_digest: digest, + snapshot_digest, + valid: false, + errors: failure.errors.into_iter().map(cross_denial).collect(), + automatic_eligible: false, + }), + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum AuthorizationState { + Invalid { errors: Vec }, + Automatic, + RequiresApprovalToken, +} + +/// Reports whether a proposal is automatic-eligible or requires a verified +/// approval token (spec 109 FR-006), without executing anything. +/// +/// # Errors +/// +/// Returns [`McpError`] only when `proposal_json` is not valid JSON. +pub fn authorization_state( + proposal_json: &str, + manifest: &ApplicationBundleManifest, + registry: &CapabilityRegistry, + limits: &ProposalLimits, +) -> Result { + let (proposal, _proposal_id, _digest) = parse_and_digest(proposal_json)?; + + let canonical = match canonicalize_proposal(proposal, limits) { + Ok(canonical) => canonical, + Err(failure) => { + return Ok(AuthorizationState::Invalid { + errors: failure.errors.into_iter().map(structural_denial).collect(), + }); + } + }; + + match validate_proposal_against_host_state(&canonical, manifest, registry) { + Ok(resolved) => Ok(if proposal_is_automatic_eligible(&resolved) { + AuthorizationState::Automatic + } else { + AuthorizationState::RequiresApprovalToken + }), + Err(failure) => Ok(AuthorizationState::Invalid { + errors: failure.errors.into_iter().map(cross_denial).collect(), + }), + } +} + +/// Everything [`execute_proposal_via_mcp`] needs beyond the caller's runtime +/// and shared authorization/quota state — bundled to keep the function +/// signature reasonable given spec 109's many independently-required checks. +pub struct ProposalExecutionRequest<'a> { + pub proposal_json: &'a str, + pub manifest: &'a ApplicationBundleManifest, + pub registry: &'a CapabilityRegistry, + pub limits: &'a ProposalLimits, + pub snapshots: &'a SnapshotDigests, + pub approval_token: Option<&'a str>, + pub expected_token_issuer: &'a str, + pub expected_token_audience: &'a str, + pub token_verifying_keys_by_key_id: &'a HashMap, + pub principal: &'a str, + pub app_id: &'a str, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ProposalExecutionResponse { + Trace(ProposalTrace), + Denied { code: String, message: String }, +} + +/// Authorizes and executes a proposal end to end (spec 109 FR-006 through +/// FR-009): validates it, decides or verifies authorization, reserves a +/// per-principal/app/workspace quota slot (FR-007b), and runs the bounded +/// sequential DAG (FR-007, FR-008). A denial for any reason — invalid +/// proposal, missing/invalid approval token, exhausted quota — is a normal +/// structured response, never an [`McpError`]. +/// +/// # Errors +/// +/// Returns [`McpError`] only when `proposal_json` is not valid JSON. +pub fn execute_proposal_via_mcp( + runtime: &Runtime, + request: &ProposalExecutionRequest<'_>, + token_store: &ApprovalTokenStore, + quota_tracker: &QuotaTracker, + quota_limits: &QuotaLimits, +) -> Result { + let (proposal, _proposal_id, digest) = parse_and_digest(request.proposal_json)?; + let workspace_id = proposal.workspace_id.clone(); + let snapshot_digest = proposal_snapshot_digest(&digest, request.snapshots); + + let canonical = match canonicalize_proposal(proposal, request.limits) { + Ok(canonical) => canonical, + Err(failure) => { + return Ok(ProposalExecutionResponse::Denied { + code: "invalid_proposal".to_string(), + message: format!("{} structural validation error(s)", failure.errors.len()), + }); + } + }; + + let resolved = match validate_proposal_against_host_state( + &canonical, + request.manifest, + request.registry, + ) { + Ok(resolved) => resolved, + Err(failure) => { + return Ok(ProposalExecutionResponse::Denied { + code: "invalid_proposal".to_string(), + message: format!("{} cross-validation error(s)", failure.errors.len()), + }); + } + }; + + let authorization = if proposal_is_automatic_eligible(&resolved) { + AuthorizationDecision::Automatic + } else { + let Some(token) = request.approval_token else { + return Ok(ProposalExecutionResponse::Denied { + code: "approval_token_required".to_string(), + message: "this proposal requires a verified approval token".to_string(), + }); + }; + let verification_context = ApprovalTokenVerificationContext { + expected_issuer: request.expected_token_issuer, + expected_audience: request.expected_token_audience, + expected_workspace_id: &workspace_id, + expected_proposal_digest: &digest, + expected_snapshot_digest: &snapshot_digest, + verifying_keys_by_key_id: request.token_verifying_keys_by_key_id, + }; + let claims = match verify_approval_token(token, &verification_context) { + Ok(claims) => claims, + Err(error) => { + return Ok(ProposalExecutionResponse::Denied { + code: token_error_code(&error.code), + message: error.message, + }); + } + }; + if let Err(error) = token_store.check_and_record_use(&claims) { + return Ok(ProposalExecutionResponse::Denied { + code: token_error_code(&error.code), + message: error.message, + }); + } + AuthorizationDecision::Approved(Box::new(claims)) + }; + + let reservation = match quota_tracker.reserve( + request.principal, + request.app_id, + &workspace_id, + quota_limits, + ) { + Ok(reservation) => reservation, + Err(denial) => { + return Ok(ProposalExecutionResponse::Denied { + code: format!("quota_exhausted_{}", denial.scope), + message: denial.message, + }); + } + }; + + let authorization_summary = match &authorization { + AuthorizationDecision::Automatic => AuthorizationSummary { + automatic: true, + approval_token_id: None, + }, + AuthorizationDecision::Approved(claims) => AuthorizationSummary { + automatic: false, + approval_token_id: Some(claims.token_id.clone()), + }, + }; + + let trace = execute_proposal( + runtime, + &canonical, + &resolved, + authorization_summary, + &digest, + &snapshot_digest, + ); + drop(reservation); + Ok(ProposalExecutionResponse::Trace(trace)) +} + +/// Renders a completed execution's redacted trace for MCP observation (spec +/// 109 FR-001 observation, FR-009). The trace itself already excludes raw +/// payloads and secrets — this is a plain JSON projection, not a second +/// redaction pass. +#[must_use] +pub fn observe_proposal(trace: &ProposalTrace) -> Value { + serde_json::to_value(trace).unwrap_or(Value::Null) +} + +#[derive(Debug, Clone, Serialize)] +pub struct ProposalExportResponse { + pub proposal: WorkflowProposal, + pub proposal_digest: String, + pub execution_order: Vec, +} + +/// Exports a proposal's canonical form and digest so an external party can +/// independently re-derive the identical digest (spec 109 FR-001 export, +/// FR-007a: "re-submitting pinned identical inputs produces the same +/// proposal digest"). Performs structural validation only — no manifest or +/// registry cross-check. +/// +/// # Errors +/// +/// Returns [`McpError`] when `proposal_json` is not valid JSON or is not +/// structurally valid. +pub fn export_proposal( + proposal_json: &str, + limits: &ProposalLimits, +) -> Result { + let (proposal, _proposal_id, digest) = parse_and_digest(proposal_json)?; + let canonical = canonicalize_proposal(proposal, limits).map_err(|failure| McpError { + code: McpErrorCode::ValidationFailed, + message: format!( + "proposal is not structurally valid ({} error(s))", + failure.errors.len() + ), + })?; + Ok(ProposalExportResponse { + proposal: canonical.proposal, + proposal_digest: digest, + execution_order: canonical.execution_order, + }) +} + +fn parse_and_digest(proposal_json: &str) -> Result<(WorkflowProposal, String, String), McpError> { + let proposal: WorkflowProposal = serde_json::from_str(proposal_json).map_err(|e| McpError { + code: McpErrorCode::InvalidRequest, + message: format!("proposal is not valid JSON: {e}"), + })?; + let digest = proposal_digest(&proposal); + let proposal_id = proposal.proposal_id.clone(); + Ok((proposal, proposal_id, digest)) +} + +fn structural_denial(error: StructuralError) -> ProposalDenial { + ProposalDenial { + code: debug_enum_to_snake_case(&format!("{:?}", error.code)), + path: error.path, + message: error.message, + } +} + +fn cross_denial(error: CrossError) -> ProposalDenial { + ProposalDenial { + code: debug_enum_to_snake_case(&format!("{:?}", error.code)), + path: error.path, + message: error.message, + } +} + +fn token_error_code(code: &traverse_runtime::proposal::ApprovalTokenErrorCode) -> String { + debug_enum_to_snake_case(&format!("{code:?}")) +} + +/// Converts a Rust `Debug`-formatted `PascalCase` enum variant into the +/// stable `snake_case` string used for every machine-readable code this +/// module emits (spec 109 FR-010). +fn debug_enum_to_snake_case(value: &str) -> String { + let mut output = String::with_capacity(value.len() + 4); + for (index, ch) in value.chars().enumerate() { + if ch.is_ascii_uppercase() { + if index > 0 { + output.push('_'); + } + output.push(ch.to_ascii_lowercase()); + } else { + output.push(ch); + } + } + output +} diff --git a/crates/traverse-mcp/tests/proposal_tests.rs b/crates/traverse-mcp/tests/proposal_tests.rs new file mode 100644 index 00000000..8a1016d9 --- /dev/null +++ b/crates/traverse-mcp/tests/proposal_tests.rs @@ -0,0 +1,1147 @@ +//! End-to-end MCP tests for the runtime workflow proposal lifecycle. +//! +//! Governed by spec `109-runtime-workflow-proposals`. Exercises the full +//! submit -> validate -> authorization-state -> execute -> observe -> export +//! path through `traverse_mcp::tools::proposals`, covering accepted, denied, +//! invalid, exhausted, and failed outcomes end to end. + +use ed25519_dalek::{Signer, SigningKey}; +use serde_json::{Value, json}; +use std::collections::HashMap; + +use traverse_contracts::{ + BinaryFormat as ContractBinaryFormat, CapabilityContract, DataFlowPolicy, DeterminismClass, + EffectClass, Entrypoint, EntrypointKind, Execution, ExecutionConstraints, ExecutionTarget, + FilesystemAccess, HostApiAccess, Lifecycle, NetworkAccess, Owner, ProposalLimits, + ReliabilityMetadata, RiskMetadata, SchemaContainer, ServiceType, SideEffect, SideEffectKind, +}; +use traverse_mcp::tools::proposals::{ + AuthorizationState, ProposalExecutionRequest, ProposalExecutionResponse, authorization_state, + execute_proposal_via_mcp, export_proposal, observe_proposal, submit_proposal, + validate_proposal, +}; +use traverse_registry::{ + ApplicationBundleManifest, ApplicationComponent, ApplicationComponentRef, + ApplicationEffectiveConfig, ArtifactDigests, BinaryFormat as RegistryBinaryFormat, + BinaryReference, CapabilityArtifactRecord, CapabilityRegistration, CapabilityRegistry, + ComponentExecutionMode, ComposabilityMetadata, CompositionKind, CompositionPattern, + ImplementationKind, RegistryProvenance, RegistryScope, SourceKind, SourceReference, + WasmComponentManifest, +}; +use traverse_runtime::proposal::{ + ApprovalTokenStore, ProposalNodeStatus, ProposalTerminalState, QuotaLimits, QuotaTracker, +}; +use traverse_runtime::security::RuntimeSecurityConfig; +use traverse_runtime::{ + LocalExecutionFailure, LocalExecutionFailureCode, LocalExecutionOutput, LocalExecutor, Runtime, +}; + +fn automatic_risk() -> RiskMetadata { + RiskMetadata { + effect_class: EffectClass::PureRead, + determinism_class: DeterminismClass::Deterministic, + data_flow: DataFlowPolicy::default(), + reliability: ReliabilityMetadata { + idempotency_required: false, + retryable: true, + compensation_available: false, + }, + } +} + +fn non_automatic_risk() -> RiskMetadata { + let mut risk = automatic_risk(); + risk.effect_class = EffectClass::ExternalEffect; + risk +} + +fn contract(id: &str, version: &str, risk: RiskMetadata) -> CapabilityContract { + let (namespace, name) = id.rsplit_once('.').unwrap_or(("test", id)); + CapabilityContract { + kind: "capability_contract".to_string(), + schema_version: "1.0.0".to_string(), + id: id.to_string(), + namespace: namespace.to_string(), + name: name.to_string(), + version: version.to_string(), + lifecycle: Lifecycle::Active, + owner: Owner { + team: "traverse-core".to_string(), + contact: "enrico.piovesan10@gmail.com".to_string(), + }, + summary: "Test capability for proposal MCP end-to-end coverage.".to_string(), + description: "Portable test capability used to exercise the proposal MCP surface." + .to_string(), + inputs: SchemaContainer { + schema: json!({"type": "object"}), + }, + outputs: SchemaContainer { + schema: json!({"type": "object"}), + }, + preconditions: Vec::new(), + postconditions: Vec::new(), + side_effects: vec![SideEffect { + kind: SideEffectKind::MemoryOnly, + description: "No durable side effect.".to_string(), + }], + emits: Vec::new(), + consumes: Vec::new(), + permissions: Vec::new(), + execution: Execution { + binary_format: ContractBinaryFormat::Wasm, + entrypoint: Entrypoint { + kind: EntrypointKind::WasiCommand, + command: "run".to_string(), + }, + preferred_targets: vec![ExecutionTarget::Local], + constraints: ExecutionConstraints { + host_api_access: HostApiAccess::None, + network_access: NetworkAccess::Forbidden, + filesystem_access: FilesystemAccess::None, + }, + }, + policies: Vec::new(), + dependencies: Vec::new(), + provenance: traverse_contracts::Provenance { + source: traverse_contracts::ProvenanceSource::Greenfield, + author: "test".to_string(), + created_at: "2026-08-23T00:00:00Z".to_string(), + spec_ref: None, + adr_refs: Vec::new(), + exception_refs: Vec::new(), + }, + evidence: Vec::new(), + service_type: ServiceType::Stateless, + permitted_targets: vec![ExecutionTarget::Local], + event_trigger: None, + connector_requirements: Vec::new(), + state_schema: None, + use_cases: Vec::new(), + risk, + } +} + +fn artifact(digest: &str) -> CapabilityArtifactRecord { + CapabilityArtifactRecord { + artifact_ref: format!("artifact:{digest}"), + implementation_kind: ImplementationKind::Executable, + source: SourceReference { + kind: SourceKind::Git, + location: "https://example.invalid/repo".to_string(), + }, + binary: Some(BinaryReference { + format: RegistryBinaryFormat::Wasm, + location: format!("artifacts/{digest}/capability.wasm"), + signature: None, + }), + workflow_ref: None, + digests: ArtifactDigests { + source_digest: format!("src-{digest}"), + binary_digest: Some(digest.to_string()), + }, + provenance: RegistryProvenance { + source: "test".to_string(), + author: "test".to_string(), + created_at: "2026-08-23T00:00:00Z".to_string(), + }, + } +} + +fn registry_with( + entries: Vec<(CapabilityContract, CapabilityArtifactRecord)>, +) -> CapabilityRegistry { + let mut registry = CapabilityRegistry::new(); + for (contract, artifact) in entries { + let outcome = registry.register(CapabilityRegistration { + scope: RegistryScope::Public, + contract, + contract_path: "registry/test/contract.json".to_string(), + artifact, + registered_at: "2026-08-23T00:00:00Z".to_string(), + tags: Vec::new(), + composability: ComposabilityMetadata { + kind: CompositionKind::Atomic, + patterns: vec![CompositionPattern::Sequential], + provides: Vec::new(), + requires: Vec::new(), + }, + governing_spec: "005-capability-registry".to_string(), + validator_version: "0.1.0".to_string(), + }); + assert!(outcome.is_ok(), "registration must succeed: {outcome:?}"); + } + registry +} + +fn manifest_declaring( + components: &[(&str, &str)], + risk: &RiskMetadata, +) -> ApplicationBundleManifest { + ApplicationBundleManifest { + app_id: "test-app".to_string(), + version: "1.0.0".to_string(), + schema_version: "1.0.0".to_string(), + workspace_defaults: json!({}), + components: components + .iter() + .map(|(capability_id, capability_version)| ApplicationComponent { + reference: ApplicationComponentRef { + component_id: (*capability_id).to_string(), + version: (*capability_version).to_string(), + digest: "sha256:component-digest".to_string(), + manifest_path: "component.manifest.json".to_string(), + }, + manifest_path: "component.manifest.json".into(), + manifest: WasmComponentManifest { + component_id: (*capability_id).to_string(), + version: (*capability_version).to_string(), + schema_version: "1.0.0".to_string(), + execution_mode: ComponentExecutionMode::Wasm, + capability_id: (*capability_id).to_string(), + capability_version: (*capability_version).to_string(), + contract_path: None, + registry_ref: None, + wasm_binary_path: None, + wasm_digest: None, + platforms: vec!["local".to_string()], + wrapper_path: None, + runtime_constraints: json!({}), + permitted_targets: vec![ExecutionTarget::Local], + dependencies: Vec::new(), + connector_requirements: Vec::new(), + validation_evidence: Vec::new(), + executable_pin: None, + }, + contract_path: "contract.json".into(), + contract: contract(capability_id, capability_version, risk.clone()), + wasm_binary_path: None, + verified_wasm_digest: None, + }) + .collect(), + workflows: Vec::new(), + connector_bindings: Vec::new(), + model_dependencies: Vec::new(), + config_schema: json!({}), + default_config: json!({}), + effective_config: ApplicationEffectiveConfig { + values: json!({}), + redacted_secret_keys: Vec::new(), + }, + placement_policy: json!({}), + public_surfaces: Vec::new(), + state_machine: None, + } +} + +fn linear_proposal_json(proposal_id: &str) -> String { + json!({ + "kind": "workflow_proposal", + "schema_version": "1.0.0", + "proposal_id": proposal_id, + "workspace_id": "workspace-001", + "app_manifest": { + "app_id": "test-app", + "app_version": "1.0.0", + "manifest_digest": "sha256:manifest-digest" + }, + "nodes": [ + { + "node_id": "a", + "capability_id": "test.single", + "capability_version": "1.0.0", + "artifact_digest": "digest-a" + } + ], + "edges": [], + "mappings": [], + "initial_input": {} + }) + .to_string() +} + +/// Fails `canonicalize_proposal` (wrong `kind`), exercising the structural- +/// validation-failure branch of every MCP tool function. +fn structurally_invalid_proposal_json(proposal_id: &str) -> String { + json!({ + "kind": "not_a_workflow_proposal", + "schema_version": "1.0.0", + "proposal_id": proposal_id, + "workspace_id": "workspace-001", + "app_manifest": { + "app_id": "test-app", + "app_version": "1.0.0", + "manifest_digest": "sha256:manifest-digest" + }, + "nodes": [], + "edges": [], + "mappings": [], + "initial_input": {} + }) + .to_string() +} + +struct EchoExecutor; + +impl LocalExecutor for EchoExecutor { + fn execute( + &self, + _capability: &traverse_registry::ResolvedCapability, + _input: &Value, + ) -> Result { + Ok(LocalExecutionOutput { + value: json!({"status": "ok"}), + emitted_events: Vec::new(), + }) + } +} + +struct AlwaysFailingExecutor; + +impl LocalExecutor for AlwaysFailingExecutor { + fn execute( + &self, + _capability: &traverse_registry::ResolvedCapability, + _input: &Value, + ) -> Result { + Err(LocalExecutionFailure { + code: LocalExecutionFailureCode::ExecutionFailed, + message: "always fails".to_string(), + }) + } +} + +// -- validate_proposal -------------------------------------------------------- + +#[test] +fn validate_proposal_accepts_a_well_formed_automatic_proposal() -> Result<(), String> { + let manifest = manifest_declaring(&[("test.single", "1.0.0")], &automatic_risk()); + let registry = registry_with(vec![( + contract("test.single", "1.0.0", automatic_risk()), + artifact("digest-a"), + )]); + + let response = validate_proposal( + &linear_proposal_json("proposal-accept"), + &manifest, + ®istry, + &ProposalLimits::default(), + ) + .map_err(|e| format!("{e:?}"))?; + + assert!(response.valid, "errors: {:?}", response.errors); + assert!(response.errors.is_empty()); + assert!(!response.proposal_digest.is_empty()); + Ok(()) +} + +#[test] +fn validate_proposal_rejects_a_structurally_invalid_proposal() -> Result<(), String> { + let manifest = manifest_declaring(&[("test.single", "1.0.0")], &automatic_risk()); + let registry = registry_with(vec![( + contract("test.single", "1.0.0", automatic_risk()), + artifact("digest-a"), + )]); + + let response = validate_proposal( + &structurally_invalid_proposal_json("proposal-bad-kind"), + &manifest, + ®istry, + &ProposalLimits::default(), + ) + .map_err(|e| format!("{e:?}"))?; + + assert!(!response.valid); + assert!(!response.errors.is_empty()); + Ok(()) +} + +#[test] +fn validate_proposal_reports_invalid_json_as_an_mcp_error() { + let manifest = manifest_declaring(&[("test.single", "1.0.0")], &automatic_risk()); + let registry = registry_with(vec![( + contract("test.single", "1.0.0", automatic_risk()), + artifact("digest-a"), + )]); + + let result = validate_proposal("not json", &manifest, ®istry, &ProposalLimits::default()); + assert!(result.is_err()); +} + +#[test] +fn validate_proposal_rejects_a_capability_undeclared_in_the_manifest() -> Result<(), String> { + let manifest = manifest_declaring(&[("test.other", "1.0.0")], &automatic_risk()); // does not declare test.single + let registry = registry_with(vec![( + contract("test.single", "1.0.0", automatic_risk()), + artifact("digest-a"), + )]); + + let response = validate_proposal( + &linear_proposal_json("proposal-undeclared"), + &manifest, + ®istry, + &ProposalLimits::default(), + ) + .map_err(|e| format!("{e:?}"))?; + + assert!(!response.valid); + assert!( + response + .errors + .iter() + .any(|e| e.code == "undeclared_capability") + ); + Ok(()) +} + +// -- submit_proposal / authorization_state -------------------------------------------------------- + +fn snapshots() -> traverse_contracts::SnapshotDigests { + traverse_contracts::SnapshotDigests { + manifest_digest: "manifest-1".to_string(), + registry_digest: "registry-1".to_string(), + binding_digest: "binding-1".to_string(), + policy_digest: "policy-1".to_string(), + budget_digest: "budget-1".to_string(), + } +} + +#[test] +fn submit_proposal_reports_automatic_eligibility_and_a_bound_snapshot_digest() -> Result<(), String> +{ + let manifest = manifest_declaring(&[("test.single", "1.0.0")], &automatic_risk()); + let registry = registry_with(vec![( + contract("test.single", "1.0.0", automatic_risk()), + artifact("digest-a"), + )]); + + let response = submit_proposal( + &linear_proposal_json("proposal-submit"), + &manifest, + ®istry, + &ProposalLimits::default(), + &snapshots(), + ) + .map_err(|e| format!("{e:?}"))?; + + assert!(response.valid); + assert!(response.automatic_eligible); + assert!(!response.snapshot_digest.is_empty()); + assert_ne!(response.snapshot_digest, response.proposal_digest); + Ok(()) +} + +#[test] +fn submit_proposal_reports_a_structurally_invalid_proposal() -> Result<(), String> { + let manifest = manifest_declaring(&[("test.single", "1.0.0")], &automatic_risk()); + let registry = registry_with(vec![( + contract("test.single", "1.0.0", automatic_risk()), + artifact("digest-a"), + )]); + + let response = submit_proposal( + &structurally_invalid_proposal_json("proposal-submit-bad-kind"), + &manifest, + ®istry, + &ProposalLimits::default(), + &snapshots(), + ) + .map_err(|e| format!("{e:?}"))?; + + assert!(!response.valid); + assert!(!response.automatic_eligible); + assert!(!response.errors.is_empty()); + Ok(()) +} + +#[test] +fn submit_proposal_reports_a_cross_validation_invalid_proposal() -> Result<(), String> { + let manifest = manifest_declaring(&[("test.other", "1.0.0")], &automatic_risk()); // does not declare test.single + let registry = registry_with(vec![( + contract("test.single", "1.0.0", automatic_risk()), + artifact("digest-a"), + )]); + + let response = submit_proposal( + &linear_proposal_json("proposal-submit-undeclared"), + &manifest, + ®istry, + &ProposalLimits::default(), + &snapshots(), + ) + .map_err(|e| format!("{e:?}"))?; + + assert!(!response.valid); + assert!(!response.automatic_eligible); + assert!( + response + .errors + .iter() + .any(|e| e.code == "undeclared_capability") + ); + Ok(()) +} + +#[test] +fn authorization_state_is_automatic_for_an_automatic_eligible_proposal() -> Result<(), String> { + let manifest = manifest_declaring(&[("test.single", "1.0.0")], &automatic_risk()); + let registry = registry_with(vec![( + contract("test.single", "1.0.0", automatic_risk()), + artifact("digest-a"), + )]); + + let state = authorization_state( + &linear_proposal_json("proposal-auth-auto"), + &manifest, + ®istry, + &ProposalLimits::default(), + ) + .map_err(|e| format!("{e:?}"))?; + + assert_eq!(state, AuthorizationState::Automatic); + Ok(()) +} + +#[test] +fn authorization_state_requires_approval_for_a_non_automatic_proposal() -> Result<(), String> { + let manifest = manifest_declaring(&[("test.single", "1.0.0")], &non_automatic_risk()); + let registry = registry_with(vec![( + contract("test.single", "1.0.0", non_automatic_risk()), + artifact("digest-a"), + )]); + + let state = authorization_state( + &linear_proposal_json("proposal-auth-required"), + &manifest, + ®istry, + &ProposalLimits::default(), + ) + .map_err(|e| format!("{e:?}"))?; + + assert_eq!(state, AuthorizationState::RequiresApprovalToken); + Ok(()) +} + +#[test] +fn authorization_state_is_invalid_for_a_structurally_invalid_proposal() -> Result<(), String> { + let manifest = manifest_declaring(&[("test.single", "1.0.0")], &automatic_risk()); + let registry = registry_with(vec![( + contract("test.single", "1.0.0", automatic_risk()), + artifact("digest-a"), + )]); + + let state = authorization_state( + &structurally_invalid_proposal_json("proposal-auth-bad-kind"), + &manifest, + ®istry, + &ProposalLimits::default(), + ) + .map_err(|e| format!("{e:?}"))?; + + let AuthorizationState::Invalid { errors } = state else { + return Err(format!("expected Invalid, got {state:?}")); + }; + assert!(!errors.is_empty()); + Ok(()) +} + +#[test] +fn authorization_state_is_invalid_for_a_cross_validation_invalid_proposal() -> Result<(), String> { + let manifest = manifest_declaring(&[("test.other", "1.0.0")], &automatic_risk()); // does not declare test.single + let registry = registry_with(vec![( + contract("test.single", "1.0.0", automatic_risk()), + artifact("digest-a"), + )]); + + let state = authorization_state( + &linear_proposal_json("proposal-auth-undeclared"), + &manifest, + ®istry, + &ProposalLimits::default(), + ) + .map_err(|e| format!("{e:?}"))?; + + let AuthorizationState::Invalid { errors } = state else { + return Err(format!("expected Invalid, got {state:?}")); + }; + assert!(errors.iter().any(|e| e.code == "undeclared_capability")); + Ok(()) +} + +// -- execute_proposal_via_mcp -------------------------------------------------------- + +fn execution_context<'a>( + proposal_json: &'a str, + manifest: &'a ApplicationBundleManifest, + registry: &'a CapabilityRegistry, + limits: &'a ProposalLimits, + snapshots: &'a traverse_contracts::SnapshotDigests, + approval_token: Option<&'a str>, + keys: &'a HashMap, +) -> ProposalExecutionRequest<'a> { + ProposalExecutionRequest { + proposal_json, + manifest, + registry, + limits, + snapshots, + approval_token, + expected_token_issuer: "traverse-approval-service", + expected_token_audience: "traverse-runtime", + token_verifying_keys_by_key_id: keys, + principal: "principal-001", + app_id: "test-app", + } +} + +#[test] +fn execute_proposal_via_mcp_succeeds_for_an_automatic_eligible_proposal() -> Result<(), String> { + let manifest = manifest_declaring(&[("test.single", "1.0.0")], &automatic_risk()); + let registry = registry_with(vec![( + contract("test.single", "1.0.0", automatic_risk()), + artifact("digest-a"), + )]); + let runtime = Runtime::new(registry.clone(), EchoExecutor) + .with_security_config(RuntimeSecurityConfig::development()); + let token_store = ApprovalTokenStore::new(); + let quota_tracker = QuotaTracker::new(); + let keys = HashMap::new(); + let proposal_json = linear_proposal_json("proposal-exec-auto"); + + let response = execute_proposal_via_mcp( + &runtime, + &execution_context( + &proposal_json, + &manifest, + ®istry, + &ProposalLimits::default(), + &snapshots(), + None, + &keys, + ), + &token_store, + "a_tracker, + &QuotaLimits::default(), + ) + .map_err(|e| format!("{e:?}"))?; + + let ProposalExecutionResponse::Trace(trace) = response else { + return Err(format!("expected success, got {response:?}")); + }; + assert_eq!(trace.terminal_state, ProposalTerminalState::Succeeded); + assert_eq!(trace.node_outcomes[0].status, ProposalNodeStatus::Succeeded); + assert!(trace.authorization.automatic); + + let observed = observe_proposal(&trace); + assert_eq!(observed["terminal_state"], json!("succeeded")); + // The redacted trace must never carry raw payloads or secrets. + assert!(observed.get("initial_input").is_none()); + assert!(observed.get("output").is_none()); + Ok(()) +} + +#[test] +fn execute_proposal_via_mcp_denies_a_structurally_invalid_proposal() -> Result<(), String> { + let manifest = manifest_declaring(&[("test.single", "1.0.0")], &automatic_risk()); + let registry = registry_with(vec![( + contract("test.single", "1.0.0", automatic_risk()), + artifact("digest-a"), + )]); + let runtime = Runtime::new(registry.clone(), EchoExecutor) + .with_security_config(RuntimeSecurityConfig::development()); + let token_store = ApprovalTokenStore::new(); + let quota_tracker = QuotaTracker::new(); + let keys = HashMap::new(); + let proposal_json = structurally_invalid_proposal_json("proposal-exec-bad-kind"); + + let response = execute_proposal_via_mcp( + &runtime, + &execution_context( + &proposal_json, + &manifest, + ®istry, + &ProposalLimits::default(), + &snapshots(), + None, + &keys, + ), + &token_store, + "a_tracker, + &QuotaLimits::default(), + ) + .map_err(|e| format!("{e:?}"))?; + + let ProposalExecutionResponse::Denied { code, .. } = response else { + return Err(format!("expected a denial, got {response:?}")); + }; + assert_eq!(code, "invalid_proposal"); + Ok(()) +} + +#[test] +fn execute_proposal_via_mcp_denies_a_cross_validation_invalid_proposal() -> Result<(), String> { + let manifest = manifest_declaring(&[("test.other", "1.0.0")], &automatic_risk()); // does not declare test.single + let registry = registry_with(vec![( + contract("test.single", "1.0.0", automatic_risk()), + artifact("digest-a"), + )]); + let runtime = Runtime::new(registry.clone(), EchoExecutor) + .with_security_config(RuntimeSecurityConfig::development()); + let token_store = ApprovalTokenStore::new(); + let quota_tracker = QuotaTracker::new(); + let keys = HashMap::new(); + let proposal_json = linear_proposal_json("proposal-exec-undeclared"); + + let response = execute_proposal_via_mcp( + &runtime, + &execution_context( + &proposal_json, + &manifest, + ®istry, + &ProposalLimits::default(), + &snapshots(), + None, + &keys, + ), + &token_store, + "a_tracker, + &QuotaLimits::default(), + ) + .map_err(|e| format!("{e:?}"))?; + + let ProposalExecutionResponse::Denied { code, .. } = response else { + return Err(format!("expected a denial, got {response:?}")); + }; + assert_eq!(code, "invalid_proposal"); + Ok(()) +} + +#[test] +fn execute_proposal_via_mcp_denies_when_the_approval_token_use_count_is_already_exhausted() +-> Result<(), String> { + let manifest = manifest_declaring(&[("test.single", "1.0.0")], &non_automatic_risk()); + let registry = registry_with(vec![( + contract("test.single", "1.0.0", non_automatic_risk()), + artifact("digest-a"), + )]); + let runtime = Runtime::new(registry.clone(), EchoExecutor) + .with_security_config(RuntimeSecurityConfig::development()); + let token_store = ApprovalTokenStore::new(); + let quota_tracker = QuotaTracker::new(); + let signing_key = SigningKey::from_bytes(&[13_u8; 32]); + let mut keys = HashMap::new(); + keys.insert("key-1".to_string(), signing_key.verifying_key()); + + let proposal_json = linear_proposal_json("proposal-exec-use-count"); + let submission = submit_proposal( + &proposal_json, + &manifest, + ®istry, + &ProposalLimits::default(), + &snapshots(), + ) + .map_err(|e| format!("{e:?}"))?; + + // max_use_count: 1 — the token store must reject the second execute call + // with the *same* token even though its signature and digest bindings + // are still perfectly valid (spec 109 FR-006a use-count enforcement). + let token = sign_token( + &json!({ + "jti": "token-single-use-001", + "iss": "traverse-approval-service", + "aud": "traverse-runtime", + "sub": "principal-001", + "workspace_id": "workspace-001", + "proposal_digest": submission.proposal_digest, + "snapshot_digest": submission.snapshot_digest, + "permitted_effects": ["external_effect"], + "permitted_connectors": [], + "max_use_count": 1, + "exp": 4_102_444_800_i64, + }), + &signing_key, + "key-1", + ); + + let first = execute_proposal_via_mcp( + &runtime, + &execution_context( + &proposal_json, + &manifest, + ®istry, + &ProposalLimits::default(), + &snapshots(), + Some(&token), + &keys, + ), + &token_store, + "a_tracker, + &QuotaLimits::default(), + ) + .map_err(|e| format!("{e:?}"))?; + let ProposalExecutionResponse::Trace(_) = first else { + return Err(format!("expected the first use to succeed, got {first:?}")); + }; + + let second = execute_proposal_via_mcp( + &runtime, + &execution_context( + &proposal_json, + &manifest, + ®istry, + &ProposalLimits::default(), + &snapshots(), + Some(&token), + &keys, + ), + &token_store, + "a_tracker, + &QuotaLimits::default(), + ) + .map_err(|e| format!("{e:?}"))?; + + let ProposalExecutionResponse::Denied { code, .. } = second else { + return Err(format!( + "expected the second use to be denied, got {second:?}" + )); + }; + assert_eq!(code, "use_count_exhausted"); + Ok(()) +} + +#[test] +fn execute_proposal_via_mcp_denies_execution_without_a_required_approval_token() +-> Result<(), String> { + let manifest = manifest_declaring(&[("test.single", "1.0.0")], &non_automatic_risk()); + let registry = registry_with(vec![( + contract("test.single", "1.0.0", non_automatic_risk()), + artifact("digest-a"), + )]); + let runtime = Runtime::new(registry.clone(), EchoExecutor) + .with_security_config(RuntimeSecurityConfig::development()); + let token_store = ApprovalTokenStore::new(); + let quota_tracker = QuotaTracker::new(); + let keys = HashMap::new(); + let proposal_json = linear_proposal_json("proposal-exec-no-token"); + + let response = execute_proposal_via_mcp( + &runtime, + &execution_context( + &proposal_json, + &manifest, + ®istry, + &ProposalLimits::default(), + &snapshots(), + None, + &keys, + ), + &token_store, + "a_tracker, + &QuotaLimits::default(), + ) + .map_err(|e| format!("{e:?}"))?; + + let ProposalExecutionResponse::Denied { code, .. } = response else { + return Err(format!("expected a denial, got {response:?}")); + }; + assert_eq!(code, "approval_token_required"); + Ok(()) +} + +fn base64url_encode(input: &[u8]) -> String { + const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut out = String::new(); + let mut i = 0; + while i + 3 <= input.len() { + let n = + (u32::from(input[i]) << 16) | (u32::from(input[i + 1]) << 8) | u32::from(input[i + 2]); + out.push(ALPHABET[((n >> 18) & 63) as usize] as char); + out.push(ALPHABET[((n >> 12) & 63) as usize] as char); + out.push(ALPHABET[((n >> 6) & 63) as usize] as char); + out.push(ALPHABET[(n & 63) as usize] as char); + i += 3; + } + let remainder = input.len() - i; + if remainder == 1 { + let n = u32::from(input[i]) << 16; + out.push(ALPHABET[((n >> 18) & 63) as usize] as char); + out.push(ALPHABET[((n >> 12) & 63) as usize] as char); + } else if remainder == 2 { + let n = (u32::from(input[i]) << 16) | (u32::from(input[i + 1]) << 8); + out.push(ALPHABET[((n >> 18) & 63) as usize] as char); + out.push(ALPHABET[((n >> 12) & 63) as usize] as char); + out.push(ALPHABET[((n >> 6) & 63) as usize] as char); + } + out +} + +fn sign_token(payload: &Value, key: &SigningKey, key_id: &str) -> String { + let header = base64url_encode(format!(r#"{{"alg":"EdDSA","kid":"{key_id}"}}"#).as_bytes()); + let payload_b64 = base64url_encode(payload.to_string().as_bytes()); + let signing_input = format!("{header}.{payload_b64}"); + let signature = key.sign(signing_input.as_bytes()); + let signature_b64 = base64url_encode(&signature.to_bytes()); + format!("{header}.{payload_b64}.{signature_b64}") +} + +#[test] +fn execute_proposal_via_mcp_succeeds_with_a_valid_approval_token() -> Result<(), String> { + let manifest = manifest_declaring(&[("test.single", "1.0.0")], &non_automatic_risk()); + let registry = registry_with(vec![( + contract("test.single", "1.0.0", non_automatic_risk()), + artifact("digest-a"), + )]); + let runtime = Runtime::new(registry.clone(), EchoExecutor) + .with_security_config(RuntimeSecurityConfig::development()); + let token_store = ApprovalTokenStore::new(); + let quota_tracker = QuotaTracker::new(); + let signing_key = SigningKey::from_bytes(&[11_u8; 32]); + let mut keys = HashMap::new(); + keys.insert("key-1".to_string(), signing_key.verifying_key()); + + let proposal_json = linear_proposal_json("proposal-exec-with-token"); + let submission = submit_proposal( + &proposal_json, + &manifest, + ®istry, + &ProposalLimits::default(), + &snapshots(), + ) + .map_err(|e| format!("{e:?}"))?; + assert!(!submission.automatic_eligible); + + let token = sign_token( + &json!({ + "jti": "token-exec-001", + "iss": "traverse-approval-service", + "aud": "traverse-runtime", + "sub": "principal-001", + "workspace_id": "workspace-001", + "proposal_digest": submission.proposal_digest, + "snapshot_digest": submission.snapshot_digest, + "permitted_effects": ["external_effect"], + "permitted_connectors": [], + "max_use_count": 1, + "exp": 4_102_444_800_i64, + }), + &signing_key, + "key-1", + ); + + let response = execute_proposal_via_mcp( + &runtime, + &execution_context( + &proposal_json, + &manifest, + ®istry, + &ProposalLimits::default(), + &snapshots(), + Some(&token), + &keys, + ), + &token_store, + "a_tracker, + &QuotaLimits::default(), + ) + .map_err(|e| format!("{e:?}"))?; + + let ProposalExecutionResponse::Trace(trace) = response else { + return Err(format!("expected success, got {response:?}")); + }; + assert_eq!(trace.terminal_state, ProposalTerminalState::Succeeded); + assert!(!trace.authorization.automatic); + assert_eq!( + trace.authorization.approval_token_id.as_deref(), + Some("token-exec-001") + ); + Ok(()) +} + +#[test] +fn execute_proposal_via_mcp_denies_an_invalid_approval_token() -> Result<(), String> { + let manifest = manifest_declaring(&[("test.single", "1.0.0")], &non_automatic_risk()); + let registry = registry_with(vec![( + contract("test.single", "1.0.0", non_automatic_risk()), + artifact("digest-a"), + )]); + let runtime = Runtime::new(registry.clone(), EchoExecutor) + .with_security_config(RuntimeSecurityConfig::development()); + let token_store = ApprovalTokenStore::new(); + let quota_tracker = QuotaTracker::new(); + let signing_key = SigningKey::from_bytes(&[11_u8; 32]); + let wrong_key = SigningKey::from_bytes(&[12_u8; 32]); + let mut keys = HashMap::new(); + keys.insert("key-1".to_string(), signing_key.verifying_key()); + + let proposal_json = linear_proposal_json("proposal-exec-bad-token"); + let submission = submit_proposal( + &proposal_json, + &manifest, + ®istry, + &ProposalLimits::default(), + &snapshots(), + ) + .map_err(|e| format!("{e:?}"))?; + + // Signed with the wrong key. + let token = sign_token( + &json!({ + "jti": "token-bad-001", + "iss": "traverse-approval-service", + "aud": "traverse-runtime", + "sub": "principal-001", + "workspace_id": "workspace-001", + "proposal_digest": submission.proposal_digest, + "snapshot_digest": submission.snapshot_digest, + "permitted_effects": ["external_effect"], + "permitted_connectors": [], + "max_use_count": 1, + "exp": 4_102_444_800_i64, + }), + &wrong_key, + "key-1", + ); + + let response = execute_proposal_via_mcp( + &runtime, + &execution_context( + &proposal_json, + &manifest, + ®istry, + &ProposalLimits::default(), + &snapshots(), + Some(&token), + &keys, + ), + &token_store, + "a_tracker, + &QuotaLimits::default(), + ) + .map_err(|e| format!("{e:?}"))?; + + let ProposalExecutionResponse::Denied { code, .. } = response else { + return Err(format!("expected a denial, got {response:?}")); + }; + assert_eq!(code, "signature_verification_failed"); + Ok(()) +} + +#[test] +fn execute_proposal_via_mcp_denies_when_quota_is_exhausted() -> Result<(), String> { + let manifest = manifest_declaring(&[("test.single", "1.0.0")], &automatic_risk()); + let registry = registry_with(vec![( + contract("test.single", "1.0.0", automatic_risk()), + artifact("digest-a"), + )]); + let runtime = Runtime::new(registry.clone(), EchoExecutor) + .with_security_config(RuntimeSecurityConfig::development()); + let token_store = ApprovalTokenStore::new(); + let quota_tracker = QuotaTracker::new(); + let keys = HashMap::new(); + let exhausted_limits = QuotaLimits { + max_concurrent_per_principal: 0, + max_concurrent_per_app: 10, + max_concurrent_per_workspace: 10, + }; + let proposal_json = linear_proposal_json("proposal-exec-quota"); + + let response = execute_proposal_via_mcp( + &runtime, + &execution_context( + &proposal_json, + &manifest, + ®istry, + &ProposalLimits::default(), + &snapshots(), + None, + &keys, + ), + &token_store, + "a_tracker, + &exhausted_limits, + ) + .map_err(|e| format!("{e:?}"))?; + + let ProposalExecutionResponse::Denied { code, .. } = response else { + return Err(format!("expected a denial, got {response:?}")); + }; + assert_eq!(code, "quota_exhausted_principal"); + Ok(()) +} + +#[test] +fn execute_proposal_via_mcp_reports_a_failed_terminal_state_when_the_executor_fails() +-> Result<(), String> { + let manifest = manifest_declaring(&[("test.single", "1.0.0")], &automatic_risk()); + let registry = registry_with(vec![( + contract("test.single", "1.0.0", automatic_risk()), + artifact("digest-a"), + )]); + let runtime = Runtime::new(registry.clone(), AlwaysFailingExecutor) + .with_security_config(RuntimeSecurityConfig::development()); + let token_store = ApprovalTokenStore::new(); + let quota_tracker = QuotaTracker::new(); + let keys = HashMap::new(); + let proposal_json = linear_proposal_json("proposal-exec-failed"); + + let response = execute_proposal_via_mcp( + &runtime, + &execution_context( + &proposal_json, + &manifest, + ®istry, + &ProposalLimits::default(), + &snapshots(), + None, + &keys, + ), + &token_store, + "a_tracker, + &QuotaLimits::default(), + ) + .map_err(|e| format!("{e:?}"))?; + + let ProposalExecutionResponse::Trace(trace) = response else { + return Err(format!( + "expected a trace with a failed node, got {response:?}" + )); + }; + assert_eq!(trace.terminal_state, ProposalTerminalState::Failed); + assert_eq!(trace.node_outcomes[0].status, ProposalNodeStatus::Failed); + Ok(()) +} + +// -- export_proposal -------------------------------------------------------- + +#[test] +fn export_proposal_digest_matches_submit_proposal_digest() -> Result<(), String> { + let manifest = manifest_declaring(&[("test.single", "1.0.0")], &automatic_risk()); + let registry = registry_with(vec![( + contract("test.single", "1.0.0", automatic_risk()), + artifact("digest-a"), + )]); + let proposal_json = linear_proposal_json("proposal-export"); + + let submission = submit_proposal( + &proposal_json, + &manifest, + ®istry, + &ProposalLimits::default(), + &snapshots(), + ) + .map_err(|e| format!("{e:?}"))?; + let export = export_proposal(&proposal_json, &ProposalLimits::default()) + .map_err(|e| format!("{e:?}"))?; + + assert_eq!(export.proposal_digest, submission.proposal_digest); + assert_eq!(export.execution_order, vec!["a".to_string()]); + Ok(()) +} + +#[test] +fn export_proposal_rejects_a_structurally_invalid_proposal() { + let result = export_proposal( + &structurally_invalid_proposal_json("proposal-export-bad-kind"), + &ProposalLimits::default(), + ); + assert!(result.is_err()); +} diff --git a/crates/traverse-runtime/src/lib.rs b/crates/traverse-runtime/src/lib.rs index 2b6c6fca..93b49065 100644 --- a/crates/traverse-runtime/src/lib.rs +++ b/crates/traverse-runtime/src/lib.rs @@ -13,6 +13,7 @@ pub mod executor; #[cfg(feature = "native-inference")] pub mod inference; pub mod placement; +pub mod proposal; pub mod router; pub mod security; pub mod trace; diff --git a/crates/traverse-runtime/src/proposal.rs b/crates/traverse-runtime/src/proposal.rs new file mode 100644 index 00000000..e674ed41 --- /dev/null +++ b/crates/traverse-runtime/src/proposal.rs @@ -0,0 +1,2656 @@ +//! Runtime workflow proposal cross-validation, authorization, quotas, and +//! sequential execution (spec `109-runtime-workflow-proposals`, P1). +//! +//! Builds on the portable proposal types and structural validation in +//! `traverse_contracts::proposal`. This module owns everything that needs +//! live host state: cross-checking a proposal against a loaded application +//! manifest and capability registry, deciding whether it is automatic-eligible +//! or requires a verified approval token, tracking per-principal/app/workspace +//! quotas, and executing the bounded sequential DAG one node at a time. + +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; +use serde::Serialize; +use serde_json::Value; +use std::collections::{BTreeMap, HashMap}; +use std::sync::Mutex; +use std::time::{SystemTime, UNIX_EPOCH}; + +use traverse_contracts::{ + CanonicalProposal, CapabilityContract, DataClassification, EffectClass, EgressPolicy, + MappingSource, ProposalNode, is_automatic_eligible, +}; +use traverse_registry::{ApplicationBundleManifest, CapabilityRegistry, LookupScope}; + +use crate::{ + PlacementTarget, Runtime, RuntimeContext, RuntimeIntent, RuntimeLookup, RuntimeLookupScope, + RuntimeRequest, RuntimeResultStatus, +}; + +// --------------------------------------------------------------------------- +// Cross-validation against the loaded manifest and capability registry +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ProposalCrossValidationError { + pub code: ProposalCrossValidationErrorCode, + pub message: String, + pub path: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ProposalCrossValidationErrorCode { + UndeclaredCapability, + CapabilityNotFound, + ArtifactDigestMismatch, + IncompatibleMappingSchema, + UndeclaredDataClassification, + DataClassificationOverAccepted, + EgressDeniedForClassifiedMapping, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProposalCrossValidationFailure { + pub errors: Vec, +} + +/// A proposal node resolved to its exact, pinned capability contract. +#[derive(Debug, Clone, PartialEq)] +pub struct ResolvedProposalNode { + pub node_id: String, + pub contract: CapabilityContract, +} + +/// Cross-checks a structurally valid proposal against the loaded application +/// manifest and capability registry (spec 109 FR-004, FR-011): +/// +/// - every node's capability must be declared in the manifest (never an +/// undeclared capability, matching the existing "declared set is the only +/// permitted set" pattern used for manifest connector bindings), and its +/// exact pinned `artifact_digest` must match the registry's record; +/// - every mapping's source/target JSON-schema fragments must be +/// structurally compatible; +/// - every mapping's field-level data classification must be explicitly +/// declared and accepted (fail closed: an undeclared classification is +/// never treated as safe), and must not flow into a capability whose +/// egress policy denies all connectors when the classification is above +/// `Public`. +/// +/// # Errors +/// +/// Returns [`ProposalCrossValidationFailure`] on any undeclared capability, +/// digest mismatch, incompatible mapping schema, or disallowed data flow. +#[allow(clippy::too_many_lines)] +pub fn validate_proposal_against_host_state( + canonical: &CanonicalProposal, + manifest: &ApplicationBundleManifest, + registry: &CapabilityRegistry, +) -> Result, ProposalCrossValidationFailure> { + let mut errors = Vec::new(); + let mut resolved: BTreeMap = BTreeMap::new(); + + for (index, node) in canonical.proposal.nodes.iter().enumerate() { + let path = format!("$.nodes[{index}]"); + if !manifest_declares_capability(manifest, node) { + errors.push(cross_error( + ProposalCrossValidationErrorCode::UndeclaredCapability, + &path, + &format!( + "capability '{}@{}' is not declared in the application manifest", + node.capability_id, node.capability_version + ), + )); + continue; + } + + let Some(capability) = registry.find_exact( + LookupScope::PreferPrivate, + &node.capability_id, + &node.capability_version, + ) else { + errors.push(cross_error( + ProposalCrossValidationErrorCode::CapabilityNotFound, + &path, + &format!( + "capability '{}@{}' was not found in the registry", + node.capability_id, node.capability_version + ), + )); + continue; + }; + + let registry_digest = capability + .artifact + .digests + .binary_digest + .clone() + .unwrap_or_else(|| capability.artifact.digests.source_digest.clone()); + if registry_digest != node.artifact_digest { + errors.push(cross_error( + ProposalCrossValidationErrorCode::ArtifactDigestMismatch, + &format!("{path}.artifact_digest"), + &format!( + "proposal pins digest '{}' but the registry resolves '{}@{}' to digest '{registry_digest}'", + node.artifact_digest, node.capability_id, node.capability_version + ), + )); + continue; + } + + resolved.insert( + node.node_id.clone(), + ResolvedProposalNode { + node_id: node.node_id.clone(), + contract: capability.contract, + }, + ); + } + + if !errors.is_empty() { + return Err(ProposalCrossValidationFailure { errors }); + } + + for (index, mapping) in canonical.proposal.mappings.iter().enumerate() { + let path = format!("$.mappings[{index}]"); + let Some(target) = resolved.get(&mapping.target_node_id) else { + continue; + }; + + let source_output_schema = match &mapping.source { + MappingSource::InitialInput => None, + MappingSource::Node { node_id } => { + resolved.get(node_id).map(|n| &n.contract.outputs.schema) + } + }; + if let Some(source_schema) = source_output_schema + && !mapping_schema_compatible( + source_schema, + &mapping.source_path, + &target.contract.inputs.schema, + &mapping.target_path, + ) + { + errors.push(cross_error( + ProposalCrossValidationErrorCode::IncompatibleMappingSchema, + &path, + &format!( + "source path '{}' and target path '{}' declare incompatible JSON Schema types", + mapping.source_path, mapping.target_path + ), + )); + } + + let produced_classification = match &mapping.source { + MappingSource::InitialInput => None, + MappingSource::Node { node_id } => resolved.get(node_id).and_then(|source| { + classification_at_path( + &source.contract.risk.data_flow.produced_data_classifications, + &mapping.source_path, + ) + }), + }; + let Some(produced_classification) = produced_classification else { + // Initial-input-sourced mappings carry no capability-declared + // classification to check; FR-011 governs capability-to-capability + // data flow, not caller-supplied initial input. + continue; + }; + let accepted_classification = classification_at_path( + &target.contract.risk.data_flow.accepted_data_classifications, + &mapping.target_path, + ); + let Some(accepted_classification) = accepted_classification else { + errors.push(cross_error( + ProposalCrossValidationErrorCode::UndeclaredDataClassification, + &path, + &format!( + "target path '{}' on node '{}' has no declared accepted_data_classifications entry; \ + schema compatibility alone does not authorize disclosure (spec 109 FR-011)", + mapping.target_path, mapping.target_node_id + ), + )); + continue; + }; + if produced_classification > accepted_classification { + errors.push(cross_error( + ProposalCrossValidationErrorCode::DataClassificationOverAccepted, + &path, + &format!( + "source path '{}' produces data classified above what target path '{}' on node '{}' accepts", + mapping.source_path, mapping.target_path, mapping.target_node_id + ), + )); + continue; + } + let is_external_or_irreversible_effect = matches!( + target.contract.risk.effect_class, + EffectClass::ExternalEffect | EffectClass::IrreversibleEffect + ); + let egress_is_denied = target.contract.risk.data_flow.egress_policy == EgressPolicy::Denied; + if produced_classification > DataClassification::Public + && is_external_or_irreversible_effect + && egress_is_denied + { + errors.push(cross_error( + ProposalCrossValidationErrorCode::EgressDeniedForClassifiedMapping, + &path, + &format!( + "node '{}' has an external/irreversible effect with a denied egress policy and \ + cannot receive classified data from '{}'", + mapping.target_node_id, mapping.source_path + ), + )); + } + } + + if !errors.is_empty() { + return Err(ProposalCrossValidationFailure { errors }); + } + + Ok(canonical + .execution_order + .iter() + .filter_map(|node_id| resolved.get(node_id).cloned()) + .collect()) +} + +fn manifest_declares_capability(manifest: &ApplicationBundleManifest, node: &ProposalNode) -> bool { + manifest.components.iter().any(|component| { + component.manifest.capability_id == node.capability_id + && component.manifest.capability_version == node.capability_version + }) +} + +fn classification_at_path( + classifications: &[traverse_contracts::FieldDataClassification], + path: &str, +) -> Option { + classifications + .iter() + .find(|entry| entry.field_path == path) + .map(|entry| entry.classification) +} + +/// Bounded structural compatibility check between two JSON Schema fragments +/// addressed by JSON Pointer within `outputs.schema`/`inputs.schema`. Walks +/// `properties`/`items` segments to find the fragment at each path; when +/// both fragments declare a `type`, the types must match. A path that cannot +/// be resolved in either schema, or whose fragment declares no `type`, is +/// treated as compatible — schemas here document shape for humans and are +/// not held to full JSON Schema validation semantics. +fn mapping_schema_compatible( + source_schema: &Value, + source_path: &str, + target_schema: &Value, + target_path: &str, +) -> bool { + let source_fragment = resolve_schema_pointer(source_schema, source_path); + let target_fragment = resolve_schema_pointer(target_schema, target_path); + match ( + source_fragment + .and_then(|f| f.get("type")) + .and_then(Value::as_str), + target_fragment + .and_then(|f| f.get("type")) + .and_then(Value::as_str), + ) { + (Some(source_type), Some(target_type)) => source_type == target_type, + _ => true, + } +} + +fn resolve_schema_pointer<'a>(schema: &'a Value, pointer: &str) -> Option<&'a Value> { + let mut current = schema; + for segment in pointer.split('/').filter(|s| !s.is_empty()) { + current = current + .get("properties") + .and_then(|properties| properties.get(segment)) + .or_else(|| current.get("items"))?; + } + Some(current) +} + +fn cross_error( + code: ProposalCrossValidationErrorCode, + path: &str, + message: &str, +) -> ProposalCrossValidationError { + ProposalCrossValidationError { + code, + message: message.to_string(), + path: path.to_string(), + } +} + +// --------------------------------------------------------------------------- +// Authorization: automatic-eligible vs. verified approval token (FR-006, FR-006a) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AuthorizationDecision { + Automatic, + Approved(Box), +} + +/// Whether every resolved node's declared risk classes permit automatic +/// execution (spec 109 FR-006). A proposal requires an approval token the +/// moment any single node does not. +#[must_use] +pub fn proposal_is_automatic_eligible(resolved_nodes: &[ResolvedProposalNode]) -> bool { + resolved_nodes + .iter() + .all(|node| is_automatic_eligible(&node.contract.risk)) +} + +/// An approval token's verified claims (spec 109 FR-006a). Only produced by +/// [`verify_approval_token`] after signature, time, and binding checks pass. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ApprovalTokenClaims { + pub token_id: String, + pub issuer: String, + pub key_id: String, + pub audience: String, + pub principal: String, + pub workspace_id: String, + pub proposal_digest: String, + pub snapshot_digest: String, + pub permitted_effects: Vec, + pub permitted_connectors: Vec, + pub max_use_count: u32, + pub expiry_unix: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ApprovalTokenErrorCode { + Malformed, + AlgorithmNotAllowed, + UnknownKeyId, + SignatureVerificationFailed, + IssuerMismatch, + AudienceMismatch, + Expired, + WorkspaceMismatch, + ProposalDigestMismatch, + SnapshotDigestMismatch, + UseCountExhausted, + Revoked, + StoreUnavailable, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ApprovalTokenError { + pub code: ApprovalTokenErrorCode, + pub message: String, +} + +/// The context an approval token is verified against: expected issuer, +/// audience, the exact proposal/snapshot digests it must be bound to, and +/// the key registry it may be signed with (spec 109 FR-006a). +pub struct ApprovalTokenVerificationContext<'a> { + pub expected_issuer: &'a str, + pub expected_audience: &'a str, + pub expected_workspace_id: &'a str, + pub expected_proposal_digest: &'a str, + pub expected_snapshot_digest: &'a str, + pub verifying_keys_by_key_id: &'a HashMap, +} + +const APPROVAL_TOKEN_ALLOWED_ALG: &str = "EdDSA"; + +/// Verifies an approval token's Ed25519 signature, algorithm, key identity, +/// issuer, audience, expiry, and exact binding to the proposal/snapshot +/// digest and workspace (spec 109 FR-006a). Does not check use-count or +/// revocation — see [`ApprovalTokenStore`]. +/// +/// # Errors +/// +/// Returns [`ApprovalTokenError`] on any malformed, unverifiable, expired, or +/// mis-scoped token. +#[allow(clippy::too_many_lines)] +pub fn verify_approval_token( + token: &str, + context: &ApprovalTokenVerificationContext<'_>, +) -> Result { + let mut parts = token.split('.'); + let (Some(header_b64), Some(payload_b64), Some(signature_b64), None) = + (parts.next(), parts.next(), parts.next(), parts.next()) + else { + return Err(token_error( + ApprovalTokenErrorCode::Malformed, + "approval token must have exactly three dot-separated segments", + )); + }; + + let header_bytes = base64url_decode(header_b64) + .map_err(|msg| token_error(ApprovalTokenErrorCode::Malformed, &msg))?; + let header: Value = serde_json::from_slice(&header_bytes).map_err(|e| { + token_error( + ApprovalTokenErrorCode::Malformed, + &format!("invalid header: {e}"), + ) + })?; + let alg = header + .get("alg") + .and_then(Value::as_str) + .unwrap_or_default(); + if alg != APPROVAL_TOKEN_ALLOWED_ALG { + return Err(token_error( + ApprovalTokenErrorCode::AlgorithmNotAllowed, + &format!("alg '{alg}' is not allowed; only {APPROVAL_TOKEN_ALLOWED_ALG} is accepted"), + )); + } + let key_id = header + .get("kid") + .and_then(Value::as_str) + .ok_or_else(|| token_error(ApprovalTokenErrorCode::Malformed, "header missing 'kid'"))? + .to_string(); + let verifying_key = context + .verifying_keys_by_key_id + .get(&key_id) + .ok_or_else(|| { + token_error( + ApprovalTokenErrorCode::UnknownKeyId, + &format!("no verification key configured for key id '{key_id}'"), + ) + })?; + + let signature_bytes = base64url_decode(signature_b64) + .map_err(|msg| token_error(ApprovalTokenErrorCode::SignatureVerificationFailed, &msg))?; + let signature_array = <[u8; 64]>::try_from(signature_bytes.as_slice()).map_err(|_| { + token_error( + ApprovalTokenErrorCode::SignatureVerificationFailed, + "signature must be 64 bytes", + ) + })?; + let signature = Signature::from_bytes(&signature_array); + let signing_input = format!("{header_b64}.{payload_b64}"); + verifying_key + .verify(signing_input.as_bytes(), &signature) + .map_err(|_| { + token_error( + ApprovalTokenErrorCode::SignatureVerificationFailed, + "signature verification failed", + ) + })?; + + let payload_bytes = base64url_decode(payload_b64) + .map_err(|msg| token_error(ApprovalTokenErrorCode::Malformed, &msg))?; + let payload: Value = serde_json::from_slice(&payload_bytes).map_err(|e| { + token_error( + ApprovalTokenErrorCode::Malformed, + &format!("invalid payload: {e}"), + ) + })?; + + let claims = parse_approval_token_claims(&payload, &key_id)?; + + if claims.issuer != context.expected_issuer { + return Err(token_error( + ApprovalTokenErrorCode::IssuerMismatch, + "token issuer does not match the expected issuer", + )); + } + if claims.audience != context.expected_audience { + return Err(token_error( + ApprovalTokenErrorCode::AudienceMismatch, + "token audience does not match the expected audience", + )); + } + if claims.workspace_id != context.expected_workspace_id { + return Err(token_error( + ApprovalTokenErrorCode::WorkspaceMismatch, + "token workspace_id does not match the proposal's workspace_id", + )); + } + if claims.proposal_digest != context.expected_proposal_digest { + return Err(token_error( + ApprovalTokenErrorCode::ProposalDigestMismatch, + "token is not bound to this exact proposal digest", + )); + } + if claims.snapshot_digest != context.expected_snapshot_digest { + return Err(token_error( + ApprovalTokenErrorCode::SnapshotDigestMismatch, + "token is not bound to the current pinned snapshot digest", + )); + } + let now = unix_now(); + if claims.expiry_unix <= now { + return Err(token_error( + ApprovalTokenErrorCode::Expired, + "token is expired", + )); + } + + Ok(claims) +} + +fn parse_approval_token_claims( + payload: &Value, + key_id: &str, +) -> Result { + let get_str = |field: &str| -> Result { + payload + .get(field) + .and_then(Value::as_str) + .filter(|s| !s.trim().is_empty()) + .map(ToString::to_string) + .ok_or_else(|| { + token_error( + ApprovalTokenErrorCode::Malformed, + &format!("payload missing required non-empty claim '{field}'"), + ) + }) + }; + let permitted_effects = payload + .get("permitted_effects") + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .filter_map(parse_effect_class) + .collect() + }) + .unwrap_or_default(); + let permitted_connectors = payload + .get("permitted_connectors") + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .map(ToString::to_string) + .collect() + }) + .unwrap_or_default(); + let max_use_count = payload + .get("max_use_count") + .and_then(Value::as_u64) + .and_then(|v| u32::try_from(v).ok()) + .ok_or_else(|| { + token_error( + ApprovalTokenErrorCode::Malformed, + "payload missing required u32 claim 'max_use_count'", + ) + })?; + let expiry_unix = payload.get("exp").and_then(Value::as_i64).ok_or_else(|| { + token_error( + ApprovalTokenErrorCode::Malformed, + "payload missing required claim 'exp'", + ) + })?; + + Ok(ApprovalTokenClaims { + token_id: get_str("jti")?, + issuer: get_str("iss")?, + key_id: key_id.to_string(), + audience: get_str("aud")?, + principal: get_str("sub")?, + workspace_id: get_str("workspace_id")?, + proposal_digest: get_str("proposal_digest")?, + snapshot_digest: get_str("snapshot_digest")?, + permitted_effects, + permitted_connectors, + max_use_count, + expiry_unix, + }) +} + +fn parse_effect_class(value: &str) -> Option { + match value { + "pure_read" => Some(EffectClass::PureRead), + "state_write" => Some(EffectClass::StateWrite), + "external_effect" => Some(EffectClass::ExternalEffect), + "irreversible_effect" => Some(EffectClass::IrreversibleEffect), + _ => None, + } +} + +fn token_error(code: ApprovalTokenErrorCode, message: &str) -> ApprovalTokenError { + ApprovalTokenError { + code, + message: message.to_string(), + } +} + +fn unix_now() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)) +} + +fn base64url_decode(input: &str) -> Result, String> { + const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut lookup = [255u8; 256]; + for (index, byte) in ALPHABET.iter().enumerate() { + lookup[*byte as usize] = u8::try_from(index).unwrap_or(0); + } + + let bytes = input.as_bytes(); + let mut out = Vec::with_capacity(bytes.len() * 3 / 4 + 3); + let mut buffer: u32 = 0; + let mut bits: u32 = 0; + for &byte in bytes { + let value = lookup[byte as usize]; + if value == 255 { + return Err("invalid base64url character".to_string()); + } + buffer = (buffer << 6) | u32::from(value); + bits += 6; + if bits >= 8 { + bits -= 8; + out.push(u8::try_from((buffer >> bits) & 0xFF).unwrap_or(0)); + } + } + Ok(out) +} + +/// Tracks approval-token use-count and revocation across calls (spec 109 +/// FR-006a: max use count, revocation, replay prevention). In-memory only — +/// tokens do not survive a process restart, which is safe since they are +/// short-lived and bound to a specific pinned snapshot. +pub struct ApprovalTokenStore { + used: Mutex>, +} + +#[derive(Debug, Clone, Copy, Default)] +struct TokenUsageRecord { + use_count: u32, + revoked: bool, +} + +impl Default for ApprovalTokenStore { + fn default() -> Self { + Self::new() + } +} + +impl ApprovalTokenStore { + #[must_use] + pub fn new() -> Self { + Self { + used: Mutex::new(HashMap::new()), + } + } + + /// Atomically checks revocation and use-count, then records one use. + /// + /// # Errors + /// + /// Returns [`ApprovalTokenError`] with [`ApprovalTokenErrorCode::Revoked`] + /// or [`ApprovalTokenErrorCode::UseCountExhausted`] when the token cannot + /// be used again, or [`ApprovalTokenErrorCode::StoreUnavailable`] (fail + /// closed) if the internal store is poisoned by a prior panicking holder. + pub fn check_and_record_use( + &self, + claims: &ApprovalTokenClaims, + ) -> Result<(), ApprovalTokenError> { + let mut used = self.used.lock().map_err(|_| { + token_error( + ApprovalTokenErrorCode::StoreUnavailable, + "approval token store is unavailable; failing closed", + ) + })?; + let record = used.entry(claims.token_id.clone()).or_default(); + if record.revoked { + return Err(token_error( + ApprovalTokenErrorCode::Revoked, + "token has been revoked", + )); + } + if record.use_count >= claims.max_use_count { + return Err(token_error( + ApprovalTokenErrorCode::UseCountExhausted, + "token has reached its maximum use count", + )); + } + record.use_count += 1; + Ok(()) + } + + /// Marks a token id as permanently unusable, regardless of remaining + /// uses (spec 109 FR-006a revocation). A poisoned store is a no-op — + /// there is nothing further to record, and `check_and_record_use` fails + /// closed independently. + pub fn revoke(&self, token_id: &str) { + if let Ok(mut used) = self.used.lock() { + used.entry(token_id.to_string()).or_default().revoked = true; + } + } +} + +// --------------------------------------------------------------------------- +// Per-principal/app/workspace quota tracking (spec 109 FR-007b) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct QuotaLimits { + pub max_concurrent_per_principal: u32, + pub max_concurrent_per_app: u32, + pub max_concurrent_per_workspace: u32, +} + +pub const DEFAULT_MAX_CONCURRENT_PER_PRINCIPAL: u32 = 4; +pub const DEFAULT_MAX_CONCURRENT_PER_APP: u32 = 16; +pub const DEFAULT_MAX_CONCURRENT_PER_WORKSPACE: u32 = 32; + +impl Default for QuotaLimits { + fn default() -> Self { + Self { + max_concurrent_per_principal: DEFAULT_MAX_CONCURRENT_PER_PRINCIPAL, + max_concurrent_per_app: DEFAULT_MAX_CONCURRENT_PER_APP, + max_concurrent_per_workspace: DEFAULT_MAX_CONCURRENT_PER_WORKSPACE, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct QuotaDenial { + pub scope: &'static str, + pub message: String, +} + +/// A live concurrency reservation. Releases automatically on drop so a +/// caller can never forget to release it, even on an early error return. +#[derive(Debug)] +pub struct QuotaReservation<'a> { + tracker: &'a QuotaTracker, + principal: String, + app_id: String, + workspace_id: String, +} + +impl Drop for QuotaReservation<'_> { + fn drop(&mut self) { + self.tracker + .release(&self.principal, &self.app_id, &self.workspace_id); + } +} + +/// In-memory concurrency quota ledger keyed by principal, app, and workspace +/// (spec 109 FR-007b). Each dimension is tracked and enforced independently. +#[derive(Debug, Default)] +#[allow(clippy::struct_field_names)] +pub struct QuotaTracker { + principal_slots: Mutex>, + app_slots: Mutex>, + workspace_slots: Mutex>, +} + +impl QuotaTracker { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Reserves one concurrent execution slot across all three dimensions, + /// or denies and reserves nothing if any dimension is already at its + /// limit. + /// + /// # Errors + /// + /// Returns [`QuotaDenial`] naming the first exhausted dimension, or a + /// `"store"`-scoped denial (fail closed) if an internal lock is poisoned + /// by a prior panicking holder. + pub fn reserve( + &self, + principal: &str, + app_id: &str, + workspace_id: &str, + limits: &QuotaLimits, + ) -> Result, QuotaDenial> { + reserve_dimension( + &self.principal_slots, + principal, + limits.max_concurrent_per_principal, + "principal", + )?; + if let Err(denial) = reserve_dimension( + &self.app_slots, + app_id, + limits.max_concurrent_per_app, + "app", + ) { + release_dimension(&self.principal_slots, principal); + return Err(denial); + } + if let Err(denial) = reserve_dimension( + &self.workspace_slots, + workspace_id, + limits.max_concurrent_per_workspace, + "workspace", + ) { + release_dimension(&self.principal_slots, principal); + release_dimension(&self.app_slots, app_id); + return Err(denial); + } + + Ok(QuotaReservation { + tracker: self, + principal: principal.to_string(), + app_id: app_id.to_string(), + workspace_id: workspace_id.to_string(), + }) + } + + fn release(&self, principal: &str, app_id: &str, workspace_id: &str) { + release_dimension(&self.principal_slots, principal); + release_dimension(&self.app_slots, app_id); + release_dimension(&self.workspace_slots, workspace_id); + } +} + +fn reserve_dimension( + counts: &Mutex>, + key: &str, + limit: u32, + scope: &'static str, +) -> Result<(), QuotaDenial> { + let mut counts = counts.lock().map_err(|_| QuotaDenial { + scope: "store", + message: "quota tracker is unavailable; failing closed".to_string(), + })?; + let count = counts.entry(key.to_string()).or_insert(0); + if *count >= limit { + return Err(QuotaDenial { + scope, + message: format!("{scope} '{key}' is at its concurrency limit of {limit}"), + }); + } + *count += 1; + Ok(()) +} + +fn release_dimension(counts: &Mutex>, key: &str) { + if let Ok(mut counts) = counts.lock() + && let Some(count) = counts.get_mut(key) + { + *count = count.saturating_sub(1); + } +} + +// --------------------------------------------------------------------------- +// Sequential execution and redacted trace (spec 109 FR-007, FR-008, FR-008a, FR-009) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ProposalNodeStatus { + Succeeded, + Failed, + SkippedAfterEarlierFailure, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ProposalNodeOutcome { + pub node_id: String, + pub capability_id: String, + pub capability_version: String, + pub artifact_digest: String, + pub status: ProposalNodeStatus, + pub error_code: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ProposalTerminalState { + Succeeded, + Failed, + Cancelled, + Expired, + AuthorizationRevoked, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AuthorizationSummary { + pub automatic: bool, + pub approval_token_id: Option, +} + +/// A bounded, redacted, immutable projection of one proposal execution (spec +/// 109 FR-009). Carries mapping *paths*, never mapped values; a token id, +/// never the raw token; and no raw node input/output payloads. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ProposalTrace { + pub proposal_id: String, + pub proposal_digest: String, + pub snapshot_digest: String, + pub authorization: AuthorizationSummary, + pub node_outcomes: Vec, + pub mapping_paths: Vec<(String, String)>, + pub terminal_state: ProposalTerminalState, +} + +/// Executes a structurally and cross-validated proposal one node at a time in +/// its deterministic execution order, threading data between nodes solely +/// through the proposal's explicit mappings. Stops at the first failed node +/// with no retry, compensation, or graph mutation (spec 109 FR-008). +#[must_use] +#[allow(clippy::too_many_lines)] +pub fn execute_proposal( + runtime: &Runtime, + canonical: &CanonicalProposal, + resolved_nodes: &[ResolvedProposalNode], + authorization: AuthorizationSummary, + proposal_digest: &str, + snapshot_digest: &str, +) -> ProposalTrace { + let contracts_by_node: HashMap<&str, &CapabilityContract> = resolved_nodes + .iter() + .map(|n| (n.node_id.as_str(), &n.contract)) + .collect(); + let nodes_by_id: HashMap<&str, &ProposalNode> = canonical + .proposal + .nodes + .iter() + .map(|n| (n.node_id.as_str(), n)) + .collect(); + + let mut outputs: HashMap = HashMap::new(); + let mut outcomes = Vec::with_capacity(canonical.execution_order.len()); + let mut failed = false; + + for node_id in &canonical.execution_order { + let Some(node) = nodes_by_id.get(node_id.as_str()) else { + continue; + }; + if failed { + outcomes.push(ProposalNodeOutcome { + node_id: node_id.clone(), + capability_id: node.capability_id.clone(), + capability_version: node.capability_version.clone(), + artifact_digest: node.artifact_digest.clone(), + status: ProposalNodeStatus::SkippedAfterEarlierFailure, + error_code: None, + }); + continue; + } + + let mut input = Value::Object(serde_json::Map::new()); + for mapping in &canonical.proposal.mappings { + if mapping.target_node_id != *node_id { + continue; + } + let value = match &mapping.source { + MappingSource::InitialInput => { + pointer_get(&canonical.proposal.initial_input, &mapping.source_path) + } + MappingSource::Node { node_id: source_id } => outputs + .get(source_id) + .and_then(|output| pointer_get(output, &mapping.source_path)), + }; + if let Some(value) = value { + pointer_set(&mut input, &mapping.target_path, value.clone()); + } + } + + let request = RuntimeRequest { + kind: "runtime_request".to_string(), + schema_version: "1.0.0".to_string(), + request_id: format!("{}-{node_id}", canonical.proposal.proposal_id), + intent: RuntimeIntent { + capability_id: Some(node.capability_id.clone()), + capability_version: Some(node.capability_version.clone()), + version_range: None, + intent_key: None, + }, + input, + lookup: RuntimeLookup { + scope: RuntimeLookupScope::PreferPrivate, + allow_ambiguity: false, + }, + context: RuntimeContext { + requested_target: PlacementTarget::Local, + correlation_id: Some(canonical.proposal.proposal_id.clone()), + caller: Some("workflow_proposal".to_string()), + traceparent: None, + tracestate: None, + metadata: None, + identity: None, + }, + governing_spec: "006-runtime-request-execution".to_string(), + }; + + let outcome = runtime.execute(request); + match outcome.result.status { + RuntimeResultStatus::Completed => { + if let Some(output) = outcome.result.output.clone() { + outputs.insert(node_id.clone(), output); + } + outcomes.push(ProposalNodeOutcome { + node_id: node_id.clone(), + capability_id: node.capability_id.clone(), + capability_version: node.capability_version.clone(), + artifact_digest: node.artifact_digest.clone(), + status: ProposalNodeStatus::Succeeded, + error_code: None, + }); + } + RuntimeResultStatus::Error => { + failed = true; + outcomes.push(ProposalNodeOutcome { + node_id: node_id.clone(), + capability_id: node.capability_id.clone(), + capability_version: node.capability_version.clone(), + artifact_digest: node.artifact_digest.clone(), + status: ProposalNodeStatus::Failed, + error_code: outcome + .result + .error + .as_ref() + .map(|error| format!("{:?}", error.code)), + }); + } + } + let _ = contracts_by_node.get(node_id.as_str()); + } + + let terminal_state = if failed { + ProposalTerminalState::Failed + } else { + ProposalTerminalState::Succeeded + }; + + ProposalTrace { + proposal_id: canonical.proposal.proposal_id.clone(), + proposal_digest: proposal_digest.to_string(), + snapshot_digest: snapshot_digest.to_string(), + authorization, + node_outcomes: outcomes, + mapping_paths: canonical + .proposal + .mappings + .iter() + .map(|m| (m.source_path.clone(), m.target_path.clone())) + .collect(), + terminal_state, + } +} + +fn pointer_get<'a>(value: &'a Value, pointer: &str) -> Option<&'a Value> { + value.pointer(pointer) +} + +fn pointer_set(target: &mut Value, pointer: &str, new_value: Value) { + let segments: Vec<&str> = pointer.split('/').filter(|s| !s.is_empty()).collect(); + *target = set_at_segments(std::mem::take(target), &segments, new_value); +} + +/// Recursively rebuilds `current` with `new_value` inserted at `segments`, +/// creating intermediate objects as needed and overwriting any non-object +/// value found along the path. Every branch here is a real semantic case +/// (root replacement, descend-into-existing-object, or create-fresh-object) +/// rather than a defensive fallback, so there is no unreachable arm to guard. +fn set_at_segments(current: Value, segments: &[&str], new_value: Value) -> Value { + let Some((head, rest)) = segments.split_first() else { + return new_value; + }; + let mut map = match current { + Value::Object(map) => map, + _ => serde_json::Map::new(), + }; + let child = map.remove(*head).unwrap_or(Value::Null); + map.insert((*head).to_string(), set_at_segments(child, rest, new_value)); + Value::Object(map) +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +#[allow(clippy::panic)] +mod tests { + use super::*; + use crate::security::RuntimeSecurityConfig; + use crate::{ + LocalExecutionFailure, LocalExecutionFailureCode, LocalExecutionOutput, LocalExecutor, + Runtime, + }; + use ed25519_dalek::{Signer, SigningKey}; + use serde_json::json; + use traverse_contracts::{ + BinaryFormat as ContractBinaryFormat, CanonicalProposal, CapabilityContract, + DataClassification, DataFlowPolicy, DeterminismClass, EffectClass, EgressPolicy, + Entrypoint, EntrypointKind, Execution, ExecutionConstraints, ExecutionTarget, + FieldDataClassification, FilesystemAccess, HostApiAccess, Lifecycle, ManifestReference, + MappingSource, NetworkAccess, Owner, ProposalEdge, ProposalLimits, ProposalMapping, + ProposalNode, Provenance, ProvenanceSource, ReliabilityMetadata, RiskMetadata, + SchemaContainer, ServiceType, SideEffect, SideEffectKind, WorkflowProposal, + canonicalize_proposal, proposal_digest, + }; + use traverse_registry::{ + ApplicationBundleManifest, ApplicationComponent, ApplicationComponentRef, + ApplicationEffectiveConfig, ArtifactDigests, BinaryFormat as RegistryBinaryFormat, + BinaryReference, CapabilityArtifactRecord, CapabilityRegistration, CapabilityRegistry, + ComponentExecutionMode, ComposabilityMetadata, CompositionKind, CompositionPattern, + ImplementationKind, RegistryProvenance, RegistryScope, SourceKind, SourceReference, + WasmComponentManifest, + }; + + fn automatic_risk() -> RiskMetadata { + RiskMetadata { + effect_class: EffectClass::PureRead, + determinism_class: DeterminismClass::Deterministic, + data_flow: DataFlowPolicy::default(), + reliability: ReliabilityMetadata { + idempotency_required: false, + retryable: true, + compensation_available: false, + }, + } + } + + fn contract( + id: &str, + version: &str, + outputs_schema: Value, + inputs_schema: Value, + risk: RiskMetadata, + ) -> CapabilityContract { + let (namespace, name) = id.rsplit_once('.').unwrap_or(("test", id)); + CapabilityContract { + kind: "capability_contract".to_string(), + schema_version: "1.0.0".to_string(), + id: id.to_string(), + namespace: namespace.to_string(), + name: name.to_string(), + version: version.to_string(), + lifecycle: Lifecycle::Active, + owner: Owner { + team: "traverse-core".to_string(), + contact: "enrico.piovesan10@gmail.com".to_string(), + }, + summary: "Test capability for proposal lifecycle validation.".to_string(), + description: "Portable test capability used to validate proposal cross-checks." + .to_string(), + inputs: SchemaContainer { + schema: inputs_schema, + }, + outputs: SchemaContainer { + schema: outputs_schema, + }, + preconditions: Vec::new(), + postconditions: Vec::new(), + side_effects: vec![SideEffect { + kind: SideEffectKind::MemoryOnly, + description: "No durable side effect.".to_string(), + }], + emits: Vec::new(), + consumes: Vec::new(), + permissions: Vec::new(), + execution: Execution { + binary_format: ContractBinaryFormat::Wasm, + entrypoint: Entrypoint { + kind: EntrypointKind::WasiCommand, + command: "run".to_string(), + }, + preferred_targets: vec![ExecutionTarget::Local], + constraints: ExecutionConstraints { + host_api_access: HostApiAccess::None, + network_access: NetworkAccess::Forbidden, + filesystem_access: FilesystemAccess::None, + }, + }, + policies: Vec::new(), + dependencies: Vec::new(), + provenance: Provenance { + source: ProvenanceSource::Greenfield, + author: "test".to_string(), + created_at: "2026-08-23T00:00:00Z".to_string(), + spec_ref: None, + adr_refs: Vec::new(), + exception_refs: Vec::new(), + }, + evidence: Vec::new(), + service_type: ServiceType::Stateless, + permitted_targets: vec![ExecutionTarget::Local], + event_trigger: None, + connector_requirements: Vec::new(), + state_schema: None, + use_cases: Vec::new(), + risk, + } + } + + fn artifact(digest: &str) -> CapabilityArtifactRecord { + CapabilityArtifactRecord { + artifact_ref: format!("artifact:{digest}"), + implementation_kind: ImplementationKind::Executable, + source: SourceReference { + kind: SourceKind::Git, + location: "https://example.invalid/repo".to_string(), + }, + binary: Some(BinaryReference { + format: RegistryBinaryFormat::Wasm, + location: format!("artifacts/{digest}/capability.wasm"), + signature: None, + }), + workflow_ref: None, + digests: ArtifactDigests { + source_digest: format!("src-{digest}"), + binary_digest: Some(digest.to_string()), + }, + provenance: RegistryProvenance { + source: "test".to_string(), + author: "test".to_string(), + created_at: "2026-08-23T00:00:00Z".to_string(), + }, + } + } + + fn registry_with( + entries: Vec<(CapabilityContract, CapabilityArtifactRecord)>, + ) -> CapabilityRegistry { + let mut registry = CapabilityRegistry::new(); + for (contract, artifact) in entries { + let outcome = registry.register(CapabilityRegistration { + scope: RegistryScope::Public, + contract, + contract_path: "registry/test/contract.json".to_string(), + artifact, + registered_at: "2026-08-23T00:00:00Z".to_string(), + tags: Vec::new(), + composability: ComposabilityMetadata { + kind: CompositionKind::Atomic, + patterns: vec![CompositionPattern::Sequential], + provides: Vec::new(), + requires: Vec::new(), + }, + governing_spec: "005-capability-registry".to_string(), + validator_version: "0.1.0".to_string(), + }); + assert!(outcome.is_ok(), "registration must succeed: {outcome:?}"); + } + registry + } + + fn manifest_declaring(components: &[(&str, &str)]) -> ApplicationBundleManifest { + ApplicationBundleManifest { + app_id: "test-app".to_string(), + version: "1.0.0".to_string(), + schema_version: "1.0.0".to_string(), + workspace_defaults: json!({}), + components: components + .iter() + .map(|(capability_id, capability_version)| ApplicationComponent { + reference: ApplicationComponentRef { + component_id: capability_id.to_string(), + version: (*capability_version).to_string(), + digest: "sha256:component-digest".to_string(), + manifest_path: "component.manifest.json".to_string(), + }, + manifest_path: "component.manifest.json".into(), + manifest: WasmComponentManifest { + component_id: capability_id.to_string(), + version: (*capability_version).to_string(), + schema_version: "1.0.0".to_string(), + execution_mode: ComponentExecutionMode::Wasm, + capability_id: (*capability_id).to_string(), + capability_version: (*capability_version).to_string(), + contract_path: None, + registry_ref: None, + wasm_binary_path: None, + wasm_digest: None, + platforms: vec!["local".to_string()], + wrapper_path: None, + runtime_constraints: json!({}), + permitted_targets: vec![ExecutionTarget::Local], + dependencies: Vec::new(), + connector_requirements: Vec::new(), + validation_evidence: Vec::new(), + executable_pin: None, + }, + contract_path: "contract.json".into(), + contract: contract( + capability_id, + capability_version, + json!({"type": "object"}), + json!({"type": "object"}), + automatic_risk(), + ), + wasm_binary_path: None, + verified_wasm_digest: None, + }) + .collect(), + workflows: Vec::new(), + connector_bindings: Vec::new(), + model_dependencies: Vec::new(), + config_schema: json!({}), + default_config: json!({}), + effective_config: ApplicationEffectiveConfig { + values: json!({}), + redacted_secret_keys: Vec::new(), + }, + placement_policy: json!({}), + public_surfaces: Vec::new(), + state_machine: None, + } + } + + fn linear_proposal_source() -> WorkflowProposal { + WorkflowProposal { + kind: "workflow_proposal".to_string(), + schema_version: "1.0.0".to_string(), + proposal_id: "proposal-001".to_string(), + workspace_id: "workspace-001".to_string(), + app_manifest: ManifestReference { + app_id: "test-app".to_string(), + app_version: "1.0.0".to_string(), + manifest_digest: "sha256:manifest-digest".to_string(), + }, + nodes: vec![ + ProposalNode { + node_id: "a".to_string(), + capability_id: "test.produce".to_string(), + capability_version: "1.0.0".to_string(), + artifact_digest: "digest-a".to_string(), + }, + ProposalNode { + node_id: "b".to_string(), + capability_id: "test.consume".to_string(), + capability_version: "1.0.0".to_string(), + artifact_digest: "digest-b".to_string(), + }, + ], + edges: vec![ProposalEdge { + from_node_id: "a".to_string(), + to_node_id: "b".to_string(), + }], + mappings: vec![ProposalMapping { + source: MappingSource::Node { + node_id: "a".to_string(), + }, + source_path: "/value".to_string(), + target_node_id: "b".to_string(), + target_path: "/value".to_string(), + }], + initial_input: json!({}), + } + } + + fn linear_canonical() -> CanonicalProposal { + canonicalize_proposal(linear_proposal_source(), &ProposalLimits::default()) + .expect("linear proposal must canonicalize") + } + + fn initial_input_proposal_source() -> WorkflowProposal { + WorkflowProposal { + kind: "workflow_proposal".to_string(), + schema_version: "1.0.0".to_string(), + proposal_id: "proposal-002".to_string(), + workspace_id: "workspace-001".to_string(), + app_manifest: ManifestReference { + app_id: "test-app".to_string(), + app_version: "1.0.0".to_string(), + manifest_digest: "sha256:manifest-digest".to_string(), + }, + nodes: vec![ProposalNode { + node_id: "x".to_string(), + capability_id: "test.consume".to_string(), + capability_version: "1.0.0".to_string(), + artifact_digest: "digest-x".to_string(), + }], + edges: Vec::new(), + mappings: vec![ProposalMapping { + source: MappingSource::InitialInput, + source_path: "/value".to_string(), + target_node_id: "x".to_string(), + target_path: "/nested/value".to_string(), + }], + initial_input: json!({"value": "from-caller"}), + } + } + + fn initial_input_canonical() -> CanonicalProposal { + canonicalize_proposal(initial_input_proposal_source(), &ProposalLimits::default()) + .expect("initial-input proposal must canonicalize") + } + + // -- Cross-validation --------------------------------------------------- + + #[test] + fn validates_a_well_formed_proposal_against_manifest_and_registry() { + let manifest = manifest_declaring(&[("test.produce", "1.0.0"), ("test.consume", "1.0.0")]); + let registry = registry_with(vec![ + ( + contract( + "test.produce", + "1.0.0", + json!({"type": "object"}), + json!({"type": "object"}), + automatic_risk(), + ), + artifact("digest-a"), + ), + ( + contract( + "test.consume", + "1.0.0", + json!({"type": "object"}), + json!({"type": "object"}), + automatic_risk(), + ), + artifact("digest-b"), + ), + ]); + + let resolved = + validate_proposal_against_host_state(&linear_canonical(), &manifest, ®istry) + .expect("well-formed proposal must validate"); + assert_eq!(resolved.len(), 2); + } + + #[test] + fn rejects_a_capability_not_declared_in_the_manifest() { + let manifest = manifest_declaring(&[("test.produce", "1.0.0")]); // missing test.consume + let registry = registry_with(vec![ + ( + contract( + "test.produce", + "1.0.0", + json!({"type": "object"}), + json!({"type": "object"}), + automatic_risk(), + ), + artifact("digest-a"), + ), + ( + contract( + "test.consume", + "1.0.0", + json!({"type": "object"}), + json!({"type": "object"}), + automatic_risk(), + ), + artifact("digest-b"), + ), + ]); + + let failure = + validate_proposal_against_host_state(&linear_canonical(), &manifest, ®istry) + .expect_err("undeclared capability must be rejected"); + assert!( + failure + .errors + .iter() + .any(|e| e.code == ProposalCrossValidationErrorCode::UndeclaredCapability) + ); + } + + #[test] + fn rejects_a_capability_not_found_in_the_registry() { + let manifest = manifest_declaring(&[("test.produce", "1.0.0"), ("test.consume", "1.0.0")]); + let registry = registry_with(vec![( + contract( + "test.produce", + "1.0.0", + json!({"type": "object"}), + json!({"type": "object"}), + automatic_risk(), + ), + artifact("digest-a"), + )]); // test.consume never registered + + let failure = + validate_proposal_against_host_state(&linear_canonical(), &manifest, ®istry) + .expect_err("unregistered capability must be rejected"); + assert!( + failure + .errors + .iter() + .any(|e| e.code == ProposalCrossValidationErrorCode::CapabilityNotFound) + ); + } + + #[test] + fn rejects_an_artifact_digest_mismatch() { + let manifest = manifest_declaring(&[("test.produce", "1.0.0"), ("test.consume", "1.0.0")]); + let registry = registry_with(vec![ + ( + contract( + "test.produce", + "1.0.0", + json!({"type": "object"}), + json!({"type": "object"}), + automatic_risk(), + ), + artifact("wrong-digest"), + ), + ( + contract( + "test.consume", + "1.0.0", + json!({"type": "object"}), + json!({"type": "object"}), + automatic_risk(), + ), + artifact("digest-b"), + ), + ]); + + let failure = + validate_proposal_against_host_state(&linear_canonical(), &manifest, ®istry) + .expect_err("digest mismatch must be rejected"); + assert!( + failure + .errors + .iter() + .any(|e| e.code == ProposalCrossValidationErrorCode::ArtifactDigestMismatch) + ); + } + + #[test] + fn rejects_incompatible_mapping_schema_types() { + let manifest = manifest_declaring(&[("test.produce", "1.0.0"), ("test.consume", "1.0.0")]); + let registry = registry_with(vec![ + ( + contract( + "test.produce", + "1.0.0", + json!({"type": "object", "properties": {"value": {"type": "string"}}}), + json!({"type": "object"}), + automatic_risk(), + ), + artifact("digest-a"), + ), + ( + contract( + "test.consume", + "1.0.0", + json!({"type": "object"}), + json!({"type": "object", "properties": {"value": {"type": "integer"}}}), + automatic_risk(), + ), + artifact("digest-b"), + ), + ]); + + let failure = + validate_proposal_against_host_state(&linear_canonical(), &manifest, ®istry) + .expect_err("incompatible mapping schema types must be rejected"); + assert!( + failure + .errors + .iter() + .any(|e| e.code == ProposalCrossValidationErrorCode::IncompatibleMappingSchema) + ); + } + + fn classification_risk( + produced: &[(&str, DataClassification)], + accepted: &[(&str, DataClassification)], + egress_policy: EgressPolicy, + effect_class: EffectClass, + ) -> RiskMetadata { + RiskMetadata { + effect_class, + determinism_class: DeterminismClass::Deterministic, + data_flow: DataFlowPolicy { + accepted_data_classifications: accepted + .iter() + .map(|(path, classification)| FieldDataClassification { + field_path: (*path).to_string(), + classification: *classification, + }) + .collect(), + produced_data_classifications: produced + .iter() + .map(|(path, classification)| FieldDataClassification { + field_path: (*path).to_string(), + classification: *classification, + }) + .collect(), + egress_policy, + }, + reliability: ReliabilityMetadata { + idempotency_required: false, + retryable: true, + compensation_available: false, + }, + } + } + + #[test] + fn rejects_a_mapping_target_with_no_declared_accepted_classification() { + let manifest = manifest_declaring(&[("test.produce", "1.0.0"), ("test.consume", "1.0.0")]); + let registry = registry_with(vec![ + ( + contract( + "test.produce", + "1.0.0", + json!({"type": "object"}), + json!({"type": "object"}), + classification_risk( + &[("/value", DataClassification::Public)], + &[], + EgressPolicy::Denied, + EffectClass::PureRead, + ), + ), + artifact("digest-a"), + ), + ( + contract( + "test.consume", + "1.0.0", + json!({"type": "object"}), + json!({"type": "object"}), + automatic_risk(), // no accepted_data_classifications declared + ), + artifact("digest-b"), + ), + ]); + + let failure = + validate_proposal_against_host_state(&linear_canonical(), &manifest, ®istry) + .expect_err("undeclared target classification must be rejected"); + assert!( + failure + .errors + .iter() + .any(|e| e.code == ProposalCrossValidationErrorCode::UndeclaredDataClassification) + ); + } + + #[test] + fn rejects_a_mapping_whose_produced_classification_exceeds_accepted() { + let manifest = manifest_declaring(&[("test.produce", "1.0.0"), ("test.consume", "1.0.0")]); + let registry = registry_with(vec![ + ( + contract( + "test.produce", + "1.0.0", + json!({"type": "object"}), + json!({"type": "object"}), + classification_risk( + &[("/value", DataClassification::Confidential)], + &[], + EgressPolicy::Denied, + EffectClass::PureRead, + ), + ), + artifact("digest-a"), + ), + ( + contract( + "test.consume", + "1.0.0", + json!({"type": "object"}), + json!({"type": "object"}), + classification_risk( + &[], + &[("/value", DataClassification::Public)], + EgressPolicy::Denied, + EffectClass::PureRead, + ), + ), + artifact("digest-b"), + ), + ]); + + let failure = + validate_proposal_against_host_state(&linear_canonical(), &manifest, ®istry) + .expect_err("over-classified mapping must be rejected"); + assert!( + failure + .errors + .iter() + .any(|e| e.code == ProposalCrossValidationErrorCode::DataClassificationOverAccepted) + ); + } + + #[test] + fn rejects_classified_data_into_an_egress_denied_external_effect_node() { + let manifest = manifest_declaring(&[("test.produce", "1.0.0"), ("test.consume", "1.0.0")]); + let registry = registry_with(vec![ + ( + contract( + "test.produce", + "1.0.0", + json!({"type": "object"}), + json!({"type": "object"}), + classification_risk( + &[("/value", DataClassification::Internal)], + &[], + EgressPolicy::Denied, + EffectClass::PureRead, + ), + ), + artifact("digest-a"), + ), + ( + contract( + "test.consume", + "1.0.0", + json!({"type": "object"}), + json!({"type": "object"}), + classification_risk( + &[], + &[("/value", DataClassification::Internal)], + EgressPolicy::Denied, + EffectClass::ExternalEffect, + ), + ), + artifact("digest-b"), + ), + ]); + + let failure = + validate_proposal_against_host_state(&linear_canonical(), &manifest, ®istry) + .expect_err( + "classified data into an egress-denied external-effect node must be rejected", + ); + assert!( + failure + .errors + .iter() + .any(|e| e.code + == ProposalCrossValidationErrorCode::EgressDeniedForClassifiedMapping) + ); + } + + #[test] + fn accepts_a_mapping_sourced_from_initial_input_with_no_classification_check() { + let manifest = manifest_declaring(&[("test.consume", "1.0.0")]); + let registry = registry_with(vec![( + contract( + "test.consume", + "1.0.0", + json!({"type": "object"}), + json!({"type": "object"}), + automatic_risk(), + ), + artifact("digest-x"), + )]); + + let resolved = + validate_proposal_against_host_state(&initial_input_canonical(), &manifest, ®istry) + .expect("an initial-input-sourced mapping needs no capability-to-capability classification check"); + assert_eq!(resolved.len(), 1); + } + + #[test] + fn validate_proposal_against_host_state_skips_a_mapping_whose_target_node_is_not_resolved() { + // `mapping.target_node_id` is normally guaranteed to reference a + // declared node by `canonicalize_proposal`'s own validation, but + // `CanonicalProposal` has public fields, so a caller could construct + // one by hand bypassing that guarantee. This proves the function + // degrades gracefully (skips the dangling mapping) rather than + // panicking on such a malformed value. + let manifest = manifest_declaring(&[("test.produce", "1.0.0")]); + let registry = registry_with(vec![( + contract( + "test.produce", + "1.0.0", + json!({"type": "object"}), + json!({"type": "object"}), + automatic_risk(), + ), + artifact("digest-a"), + )]); + + let mut proposal = linear_proposal_source(); + proposal.nodes.truncate(1); + proposal.edges.clear(); + proposal.mappings[0].target_node_id = "ghost".to_string(); + let canonical = CanonicalProposal { + execution_order: vec!["a".to_string()], + proposal, + }; + + let resolved = validate_proposal_against_host_state(&canonical, &manifest, ®istry) + .expect("a dangling mapping target must not itself fail cross-validation"); + assert_eq!(resolved.len(), 1); + } + + // -- Automatic eligibility ----------------------------------------------- + + #[test] + fn proposal_is_automatic_eligible_true_when_every_node_is_automatic_eligible() { + let nodes = vec![ + ResolvedProposalNode { + node_id: "a".to_string(), + contract: contract( + "test.produce", + "1.0.0", + json!({}), + json!({}), + automatic_risk(), + ), + }, + ResolvedProposalNode { + node_id: "b".to_string(), + contract: contract( + "test.consume", + "1.0.0", + json!({}), + json!({}), + automatic_risk(), + ), + }, + ]; + assert!(proposal_is_automatic_eligible(&nodes)); + } + + #[test] + fn proposal_is_automatic_eligible_false_when_any_node_is_not() { + let mut non_automatic = automatic_risk(); + non_automatic.effect_class = EffectClass::StateWrite; + let nodes = vec![ + ResolvedProposalNode { + node_id: "a".to_string(), + contract: contract( + "test.produce", + "1.0.0", + json!({}), + json!({}), + automatic_risk(), + ), + }, + ResolvedProposalNode { + node_id: "b".to_string(), + contract: contract("test.consume", "1.0.0", json!({}), json!({}), non_automatic), + }, + ]; + assert!(!proposal_is_automatic_eligible(&nodes)); + } + + // -- Approval token verification ----------------------------------------- + + fn signing_key() -> SigningKey { + SigningKey::from_bytes(&[9_u8; 32]) + } + + fn base64url_encode(input: &[u8]) -> String { + const ALPHABET: &[u8; 64] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut out = String::new(); + let mut i = 0; + while i + 3 <= input.len() { + let n = (u32::from(input[i]) << 16) + | (u32::from(input[i + 1]) << 8) + | u32::from(input[i + 2]); + out.push(ALPHABET[((n >> 18) & 63) as usize] as char); + out.push(ALPHABET[((n >> 12) & 63) as usize] as char); + out.push(ALPHABET[((n >> 6) & 63) as usize] as char); + out.push(ALPHABET[(n & 63) as usize] as char); + i += 3; + } + let remainder = input.len() - i; + if remainder == 1 { + let n = u32::from(input[i]) << 16; + out.push(ALPHABET[((n >> 18) & 63) as usize] as char); + out.push(ALPHABET[((n >> 12) & 63) as usize] as char); + } else if remainder == 2 { + let n = (u32::from(input[i]) << 16) | (u32::from(input[i + 1]) << 8); + out.push(ALPHABET[((n >> 18) & 63) as usize] as char); + out.push(ALPHABET[((n >> 12) & 63) as usize] as char); + out.push(ALPHABET[((n >> 6) & 63) as usize] as char); + } + out + } + + fn sign_token(payload: &Value, key: &SigningKey, key_id: &str) -> String { + let header = base64url_encode(format!(r#"{{"alg":"EdDSA","kid":"{key_id}"}}"#).as_bytes()); + let payload_b64 = base64url_encode(payload.to_string().as_bytes()); + let signing_input = format!("{header}.{payload_b64}"); + let signature = key.sign(signing_input.as_bytes()); + let signature_b64 = base64url_encode(&signature.to_bytes()); + format!("{header}.{payload_b64}.{signature_b64}") + } + + fn valid_claims_payload() -> Value { + json!({ + "jti": "token-001", + "iss": "traverse-approval-service", + "aud": "traverse-runtime", + "sub": "principal-001", + "workspace_id": "workspace-001", + "proposal_digest": "digest-p", + "snapshot_digest": "digest-s", + "permitted_effects": ["external_effect"], + "permitted_connectors": ["traverse.http"], + "max_use_count": 1, + "exp": 4_102_444_800_i64, // 2100-01-01, far future + }) + } + + fn verification_context( + keys: &HashMap, + ) -> ApprovalTokenVerificationContext<'_> { + ApprovalTokenVerificationContext { + expected_issuer: "traverse-approval-service", + expected_audience: "traverse-runtime", + expected_workspace_id: "workspace-001", + expected_proposal_digest: "digest-p", + expected_snapshot_digest: "digest-s", + verifying_keys_by_key_id: keys, + } + } + + fn keys_with_signing_key() -> HashMap { + let mut keys = HashMap::new(); + keys.insert("key-1".to_string(), signing_key().verifying_key()); + keys + } + + #[test] + fn verifies_a_well_formed_approval_token() { + let token = sign_token(&valid_claims_payload(), &signing_key(), "key-1"); + let keys = keys_with_signing_key(); + let claims = verify_approval_token(&token, &verification_context(&keys)) + .expect("well-formed token must verify"); + assert_eq!(claims.token_id, "token-001"); + assert_eq!(claims.principal, "principal-001"); + assert_eq!(claims.permitted_effects, vec![EffectClass::ExternalEffect]); + } + + #[test] + fn rejects_malformed_token_shape() { + let keys = keys_with_signing_key(); + let failure = verify_approval_token("not-a-token", &verification_context(&keys)) + .expect_err("malformed token must be rejected"); + assert_eq!(failure.code, ApprovalTokenErrorCode::Malformed); + } + + #[test] + fn rejects_disallowed_algorithm() { + let header = base64url_encode(br#"{"alg":"HS256","kid":"key-1"}"#); + let payload = base64url_encode(valid_claims_payload().to_string().as_bytes()); + let token = format!("{header}.{payload}.sig"); + let keys = keys_with_signing_key(); + let failure = verify_approval_token(&token, &verification_context(&keys)) + .expect_err("disallowed alg must be rejected"); + assert_eq!(failure.code, ApprovalTokenErrorCode::AlgorithmNotAllowed); + } + + #[test] + fn rejects_unknown_key_id() { + let token = sign_token(&valid_claims_payload(), &signing_key(), "unknown-key"); + let keys = keys_with_signing_key(); + let failure = verify_approval_token(&token, &verification_context(&keys)) + .expect_err("unknown key id must be rejected"); + assert_eq!(failure.code, ApprovalTokenErrorCode::UnknownKeyId); + } + + #[test] + fn rejects_bad_signature() { + let other_key = SigningKey::from_bytes(&[3_u8; 32]); + let token = sign_token(&valid_claims_payload(), &other_key, "key-1"); + let keys = keys_with_signing_key(); + let failure = verify_approval_token(&token, &verification_context(&keys)) + .expect_err("signature from the wrong key must be rejected"); + assert_eq!( + failure.code, + ApprovalTokenErrorCode::SignatureVerificationFailed + ); + } + + #[test] + fn rejects_issuer_mismatch() { + let mut payload = valid_claims_payload(); + payload["iss"] = json!("someone-else"); + let token = sign_token(&payload, &signing_key(), "key-1"); + let keys = keys_with_signing_key(); + let failure = verify_approval_token(&token, &verification_context(&keys)) + .expect_err("issuer mismatch must be rejected"); + assert_eq!(failure.code, ApprovalTokenErrorCode::IssuerMismatch); + } + + #[test] + fn rejects_audience_mismatch() { + let mut payload = valid_claims_payload(); + payload["aud"] = json!("someone-else"); + let token = sign_token(&payload, &signing_key(), "key-1"); + let keys = keys_with_signing_key(); + let failure = verify_approval_token(&token, &verification_context(&keys)) + .expect_err("audience mismatch must be rejected"); + assert_eq!(failure.code, ApprovalTokenErrorCode::AudienceMismatch); + } + + #[test] + fn rejects_workspace_mismatch() { + let mut payload = valid_claims_payload(); + payload["workspace_id"] = json!("someone-elses-workspace"); + let token = sign_token(&payload, &signing_key(), "key-1"); + let keys = keys_with_signing_key(); + let failure = verify_approval_token(&token, &verification_context(&keys)) + .expect_err("workspace mismatch must be rejected"); + assert_eq!(failure.code, ApprovalTokenErrorCode::WorkspaceMismatch); + } + + #[test] + fn rejects_proposal_digest_mismatch() { + let mut payload = valid_claims_payload(); + payload["proposal_digest"] = json!("different-digest"); + let token = sign_token(&payload, &signing_key(), "key-1"); + let keys = keys_with_signing_key(); + let failure = verify_approval_token(&token, &verification_context(&keys)) + .expect_err("proposal digest mismatch must be rejected"); + assert_eq!(failure.code, ApprovalTokenErrorCode::ProposalDigestMismatch); + } + + #[test] + fn rejects_snapshot_digest_mismatch() { + let mut payload = valid_claims_payload(); + payload["snapshot_digest"] = json!("different-digest"); + let token = sign_token(&payload, &signing_key(), "key-1"); + let keys = keys_with_signing_key(); + let failure = verify_approval_token(&token, &verification_context(&keys)) + .expect_err("snapshot digest mismatch must be rejected"); + assert_eq!(failure.code, ApprovalTokenErrorCode::SnapshotDigestMismatch); + } + + #[test] + fn rejects_expired_token() { + let mut payload = valid_claims_payload(); + payload["exp"] = json!(1); // 1970 + let token = sign_token(&payload, &signing_key(), "key-1"); + let keys = keys_with_signing_key(); + let failure = verify_approval_token(&token, &verification_context(&keys)) + .expect_err("expired token must be rejected"); + assert_eq!(failure.code, ApprovalTokenErrorCode::Expired); + } + + #[test] + fn rejects_a_token_with_an_invalid_base64url_character() { + let payload = base64url_encode(valid_claims_payload().to_string().as_bytes()); + let token = format!("not!valid!base64.{payload}.sig"); + let keys = keys_with_signing_key(); + let failure = verify_approval_token(&token, &verification_context(&keys)) + .expect_err("an invalid base64url character must be rejected"); + assert_eq!(failure.code, ApprovalTokenErrorCode::Malformed); + } + + #[test] + fn rejects_a_token_whose_header_is_not_valid_json() { + let header = base64url_encode(b"not-json"); + let payload = base64url_encode(valid_claims_payload().to_string().as_bytes()); + let token = format!("{header}.{payload}.sig"); + let keys = keys_with_signing_key(); + let failure = verify_approval_token(&token, &verification_context(&keys)) + .expect_err("a non-JSON header must be rejected"); + assert_eq!(failure.code, ApprovalTokenErrorCode::Malformed); + } + + #[test] + fn rejects_a_token_with_a_wrong_length_signature() { + let header = base64url_encode(br#"{"alg":"EdDSA","kid":"key-1"}"#); + let payload_b64 = base64url_encode(valid_claims_payload().to_string().as_bytes()); + let short_signature = base64url_encode(b"too-short"); + let token = format!("{header}.{payload_b64}.{short_signature}"); + let keys = keys_with_signing_key(); + let failure = verify_approval_token(&token, &verification_context(&keys)) + .expect_err("a signature that is not 64 bytes must be rejected"); + assert_eq!( + failure.code, + ApprovalTokenErrorCode::SignatureVerificationFailed + ); + } + + #[test] + fn rejects_a_token_whose_payload_is_not_valid_json() { + let key = signing_key(); + let header = base64url_encode(br#"{"alg":"EdDSA","kid":"key-1"}"#); + let payload_b64 = base64url_encode(b"not-json"); + let signing_input = format!("{header}.{payload_b64}"); + let signature = key.sign(signing_input.as_bytes()); + let signature_b64 = base64url_encode(&signature.to_bytes()); + let token = format!("{header}.{payload_b64}.{signature_b64}"); + let keys = keys_with_signing_key(); + let failure = verify_approval_token(&token, &verification_context(&keys)) + .expect_err("a non-JSON payload must be rejected"); + assert_eq!(failure.code, ApprovalTokenErrorCode::Malformed); + } + + #[test] + fn rejects_a_token_missing_a_required_string_claim() { + let mut payload = valid_claims_payload(); + payload + .as_object_mut() + .expect("payload fixture is an object") + .remove("jti"); + let token = sign_token(&payload, &signing_key(), "key-1"); + let keys = keys_with_signing_key(); + let failure = verify_approval_token(&token, &verification_context(&keys)) + .expect_err("a missing required string claim must be rejected"); + assert_eq!(failure.code, ApprovalTokenErrorCode::Malformed); + } + + #[test] + fn rejects_a_token_missing_max_use_count() { + let mut payload = valid_claims_payload(); + payload + .as_object_mut() + .expect("payload fixture is an object") + .remove("max_use_count"); + let token = sign_token(&payload, &signing_key(), "key-1"); + let keys = keys_with_signing_key(); + let failure = verify_approval_token(&token, &verification_context(&keys)) + .expect_err("a missing max_use_count claim must be rejected"); + assert_eq!(failure.code, ApprovalTokenErrorCode::Malformed); + } + + #[test] + fn rejects_a_token_missing_exp() { + let mut payload = valid_claims_payload(); + payload + .as_object_mut() + .expect("payload fixture is an object") + .remove("exp"); + let token = sign_token(&payload, &signing_key(), "key-1"); + let keys = keys_with_signing_key(); + let failure = verify_approval_token(&token, &verification_context(&keys)) + .expect_err("a missing exp claim must be rejected"); + assert_eq!(failure.code, ApprovalTokenErrorCode::Malformed); + } + + #[test] + fn accepts_a_token_permitting_an_irreversible_effect() { + let mut payload = valid_claims_payload(); + payload["permitted_effects"] = json!(["irreversible_effect"]); + let token = sign_token(&payload, &signing_key(), "key-1"); + let keys = keys_with_signing_key(); + let claims = verify_approval_token(&token, &verification_context(&keys)) + .expect("a token permitting an irreversible effect must verify"); + assert_eq!( + claims.permitted_effects, + vec![EffectClass::IrreversibleEffect] + ); + } + + #[test] + fn ignores_an_unrecognized_permitted_effect_string() { + let mut payload = valid_claims_payload(); + payload["permitted_effects"] = json!(["not_a_real_effect", "external_effect"]); + let token = sign_token(&payload, &signing_key(), "key-1"); + let keys = keys_with_signing_key(); + let claims = verify_approval_token(&token, &verification_context(&keys)) + .expect("an unrecognized effect string must be filtered out, not rejected"); + assert_eq!(claims.permitted_effects, vec![EffectClass::ExternalEffect]); + } + + #[test] + fn approval_token_store_default_starts_with_no_recorded_uses() { + let store = ApprovalTokenStore::default(); + store + .check_and_record_use(&claims_with_use_count(1)) + .expect("a freshly defaulted store has no recorded uses"); + } + + #[test] + fn check_and_record_use_fails_closed_when_the_store_mutex_is_poisoned() { + let store = ApprovalTokenStore::new(); + + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _guard = store + .used + .lock() + .expect("lock must be acquirable to poison it"); + panic!("poison approval token store lock for test"); + })); + + let failure = store + .check_and_record_use(&claims_with_use_count(10)) + .expect_err("a poisoned store must fail closed"); + assert_eq!(failure.code, ApprovalTokenErrorCode::StoreUnavailable); + } + + // -- Token store ----------------------------------------------------------- + + fn claims_with_use_count(max_use_count: u32) -> ApprovalTokenClaims { + ApprovalTokenClaims { + token_id: "token-001".to_string(), + issuer: "traverse-approval-service".to_string(), + key_id: "key-1".to_string(), + audience: "traverse-runtime".to_string(), + principal: "principal-001".to_string(), + workspace_id: "workspace-001".to_string(), + proposal_digest: "digest-p".to_string(), + snapshot_digest: "digest-s".to_string(), + permitted_effects: Vec::new(), + permitted_connectors: Vec::new(), + max_use_count, + expiry_unix: 4_102_444_800, + } + } + + #[test] + fn token_store_enforces_max_use_count() { + let store = ApprovalTokenStore::new(); + let claims = claims_with_use_count(2); + store + .check_and_record_use(&claims) + .expect("first use must succeed"); + store + .check_and_record_use(&claims) + .expect("second use must succeed"); + let failure = store + .check_and_record_use(&claims) + .expect_err("third use beyond max_use_count must be rejected"); + assert_eq!(failure.code, ApprovalTokenErrorCode::UseCountExhausted); + } + + #[test] + fn token_store_denies_a_revoked_token() { + let store = ApprovalTokenStore::new(); + let claims = claims_with_use_count(10); + store.revoke(&claims.token_id); + let failure = store + .check_and_record_use(&claims) + .expect_err("revoked token must be rejected"); + assert_eq!(failure.code, ApprovalTokenErrorCode::Revoked); + } + + // -- Quota tracker ----------------------------------------------------------- + + #[test] + fn quota_tracker_denies_when_principal_limit_reached() { + let tracker = QuotaTracker::new(); + let limits = QuotaLimits { + max_concurrent_per_principal: 1, + max_concurrent_per_app: 10, + max_concurrent_per_workspace: 10, + }; + let _first = tracker + .reserve("principal-1", "app-1", "workspace-1", &limits) + .expect("first reservation must succeed"); + let denial = tracker + .reserve("principal-1", "app-2", "workspace-2", &limits) + .expect_err("second reservation for the same principal must be denied"); + assert_eq!(denial.scope, "principal"); + } + + #[test] + fn quota_tracker_denies_when_workspace_limit_reached() { + let tracker = QuotaTracker::new(); + let limits = QuotaLimits { + max_concurrent_per_principal: 10, + max_concurrent_per_app: 10, + max_concurrent_per_workspace: 1, + }; + let _first = tracker + .reserve("principal-1", "app-1", "workspace-1", &limits) + .expect("first reservation must succeed"); + let denial = tracker + .reserve("principal-2", "app-2", "workspace-1", &limits) + .expect_err("second reservation for the same workspace must be denied"); + assert_eq!(denial.scope, "workspace"); + } + + #[test] + fn quota_tracker_releases_on_drop_allowing_reuse() { + let tracker = QuotaTracker::new(); + let limits = QuotaLimits { + max_concurrent_per_principal: 1, + max_concurrent_per_app: 1, + max_concurrent_per_workspace: 1, + }; + { + let _reservation = tracker + .reserve("principal-1", "app-1", "workspace-1", &limits) + .expect("first reservation must succeed"); + } + tracker + .reserve("principal-1", "app-1", "workspace-1", &limits) + .expect("reservation must succeed again after the first is dropped"); + } + + #[test] + fn quota_tracker_denies_when_app_limit_reached_and_rolls_back_the_principal_reservation() { + let tracker = QuotaTracker::new(); + let limits = QuotaLimits { + max_concurrent_per_principal: 1, + max_concurrent_per_app: 1, + max_concurrent_per_workspace: 10, + }; + let _first = tracker + .reserve("principal-1", "app-1", "workspace-1", &limits) + .expect("first reservation must succeed"); + + let denial = tracker + .reserve("principal-2", "app-1", "workspace-2", &limits) + .expect_err("second reservation against the same app must be denied"); + assert_eq!(denial.scope, "app"); + + // If the principal-dimension reservation taken just before the + // app-dimension denial were not rolled back, principal-2 would + // already be at its limit and this would incorrectly fail too. + tracker + .reserve("principal-2", "app-2", "workspace-3", &limits) + .expect("principal-2's slot must have been released by the app-limit rollback"); + } + + #[test] + fn quota_limits_default_matches_the_documented_defaults() { + let limits = QuotaLimits::default(); + assert_eq!( + limits.max_concurrent_per_principal, + DEFAULT_MAX_CONCURRENT_PER_PRINCIPAL + ); + assert_eq!( + limits.max_concurrent_per_app, + DEFAULT_MAX_CONCURRENT_PER_APP + ); + assert_eq!( + limits.max_concurrent_per_workspace, + DEFAULT_MAX_CONCURRENT_PER_WORKSPACE + ); + } + + #[test] + fn reserve_fails_closed_when_a_quota_dimension_mutex_is_poisoned() { + let tracker = QuotaTracker::new(); + + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _guard = tracker + .principal_slots + .lock() + .expect("lock must be acquirable to poison it"); + panic!("poison quota tracker principal_slots lock for test"); + })); + + let limits = QuotaLimits { + max_concurrent_per_principal: 10, + max_concurrent_per_app: 10, + max_concurrent_per_workspace: 10, + }; + let denial = tracker + .reserve("principal-1", "app-1", "workspace-1", &limits) + .expect_err("a poisoned quota dimension must fail closed"); + assert_eq!(denial.scope, "store"); + } + + // -- Execution engine ----------------------------------------------------------- + + #[derive(Default)] + struct MappingAwareExecutor { + consumer_saw_input: std::sync::Arc>>, + } + + impl LocalExecutor for MappingAwareExecutor { + fn execute( + &self, + capability: &traverse_registry::ResolvedCapability, + input: &Value, + ) -> Result { + if capability.contract.id == "test.produce" { + Ok(LocalExecutionOutput { + value: json!({"value": "produced-by-a"}), + emitted_events: Vec::new(), + }) + } else { + if let Ok(mut seen) = self.consumer_saw_input.lock() { + *seen = Some(input.clone()); + } + Ok(LocalExecutionOutput { + value: json!({"received": input.clone()}), + emitted_events: Vec::new(), + }) + } + } + } + + struct AlwaysFailingExecutor; + + impl LocalExecutor for AlwaysFailingExecutor { + fn execute( + &self, + _capability: &traverse_registry::ResolvedCapability, + _input: &Value, + ) -> Result { + Err(LocalExecutionFailure { + code: LocalExecutionFailureCode::ExecutionFailed, + message: "always fails".to_string(), + }) + } + } + + fn resolved_nodes() -> Vec { + vec![ + ResolvedProposalNode { + node_id: "a".to_string(), + contract: contract( + "test.produce", + "1.0.0", + json!({}), + json!({}), + automatic_risk(), + ), + }, + ResolvedProposalNode { + node_id: "b".to_string(), + contract: contract( + "test.consume", + "1.0.0", + json!({}), + json!({}), + automatic_risk(), + ), + }, + ] + } + + #[test] + fn executes_a_linear_proposal_threading_mapped_data_between_nodes() { + let registry = registry_with(vec![ + ( + contract( + "test.produce", + "1.0.0", + json!({}), + json!({}), + automatic_risk(), + ), + artifact("digest-a"), + ), + ( + contract( + "test.consume", + "1.0.0", + json!({}), + json!({}), + automatic_risk(), + ), + artifact("digest-b"), + ), + ]); + let consumer_saw_input = std::sync::Arc::new(Mutex::new(None)); + let executor = MappingAwareExecutor { + consumer_saw_input: consumer_saw_input.clone(), + }; + let runtime = Runtime::new(registry, executor) + .with_security_config(RuntimeSecurityConfig::development()); + + let canonical = linear_canonical(); + let digest = proposal_digest(&canonical.proposal); + let trace = execute_proposal( + &runtime, + &canonical, + &resolved_nodes(), + AuthorizationSummary { + automatic: true, + approval_token_id: None, + }, + &digest, + "snapshot-digest", + ); + + assert_eq!(trace.terminal_state, ProposalTerminalState::Succeeded); + assert_eq!(trace.node_outcomes.len(), 2); + assert_eq!(trace.node_outcomes[0].status, ProposalNodeStatus::Succeeded); + assert_eq!(trace.node_outcomes[1].status, ProposalNodeStatus::Succeeded); + + let seen_input = consumer_saw_input + .lock() + .expect("test mutex must not be poisoned") + .clone() + .expect("consumer node must have executed and recorded its input"); + assert_eq!( + seen_input, + json!({"value": "produced-by-a"}), + "node b's input must be assembled solely from the declared mapping, not node a's full output" + ); + } + + #[test] + fn stops_at_first_failure_and_skips_remaining_nodes() { + let registry = registry_with(vec![ + ( + contract( + "test.produce", + "1.0.0", + json!({}), + json!({}), + automatic_risk(), + ), + artifact("digest-a"), + ), + ( + contract( + "test.consume", + "1.0.0", + json!({}), + json!({}), + automatic_risk(), + ), + artifact("digest-b"), + ), + ]); + let runtime = Runtime::new(registry, AlwaysFailingExecutor) + .with_security_config(RuntimeSecurityConfig::development()); + + let canonical = linear_canonical(); + let digest = proposal_digest(&canonical.proposal); + let trace = execute_proposal( + &runtime, + &canonical, + &resolved_nodes(), + AuthorizationSummary { + automatic: true, + approval_token_id: None, + }, + &digest, + "snapshot-digest", + ); + + assert_eq!(trace.terminal_state, ProposalTerminalState::Failed); + assert_eq!(trace.node_outcomes[0].status, ProposalNodeStatus::Failed); + assert_eq!( + trace.node_outcomes[1].status, + ProposalNodeStatus::SkippedAfterEarlierFailure + ); + } + + #[test] + fn executes_a_mapping_sourced_from_initial_input_into_a_nested_target_path() { + let registry = registry_with(vec![( + contract( + "test.consume", + "1.0.0", + json!({}), + json!({}), + automatic_risk(), + ), + artifact("digest-x"), + )]); + let consumer_saw_input = std::sync::Arc::new(Mutex::new(None)); + let executor = MappingAwareExecutor { + consumer_saw_input: consumer_saw_input.clone(), + }; + let runtime = Runtime::new(registry, executor) + .with_security_config(RuntimeSecurityConfig::development()); + + let canonical = initial_input_canonical(); + let digest = proposal_digest(&canonical.proposal); + let trace = execute_proposal( + &runtime, + &canonical, + &[ResolvedProposalNode { + node_id: "x".to_string(), + contract: contract( + "test.consume", + "1.0.0", + json!({}), + json!({}), + automatic_risk(), + ), + }], + AuthorizationSummary { + automatic: true, + approval_token_id: None, + }, + &digest, + "snapshot-digest", + ); + + assert_eq!(trace.terminal_state, ProposalTerminalState::Succeeded); + let seen_input = consumer_saw_input + .lock() + .expect("test mutex must not be poisoned") + .clone() + .expect("consumer must have received input"); + assert_eq!(seen_input["nested"]["value"], json!("from-caller")); + } + + #[test] + fn execute_proposal_skips_an_execution_order_entry_with_no_matching_node() { + // `execution_order` is normally guaranteed to be a permutation of + // `proposal.nodes`' ids by `canonicalize_proposal`, but + // `CanonicalProposal` has public fields, so a caller could construct + // one by hand with a mismatched entry. This proves execution degrades + // gracefully (skips the unmatched entry) rather than panicking. + let registry = registry_with(vec![( + contract( + "test.produce", + "1.0.0", + json!({}), + json!({}), + automatic_risk(), + ), + artifact("digest-a"), + )]); + let runtime = Runtime::new(registry, MappingAwareExecutor::default()) + .with_security_config(RuntimeSecurityConfig::development()); + + let mut proposal = linear_proposal_source(); + proposal.nodes.truncate(1); + proposal.edges.clear(); + proposal.mappings.clear(); + let canonical = CanonicalProposal { + execution_order: vec!["a".to_string(), "ghost".to_string()], + proposal, + }; + let digest = proposal_digest(&canonical.proposal); + let trace = execute_proposal( + &runtime, + &canonical, + &[ResolvedProposalNode { + node_id: "a".to_string(), + contract: contract( + "test.produce", + "1.0.0", + json!({}), + json!({}), + automatic_risk(), + ), + }], + AuthorizationSummary { + automatic: true, + approval_token_id: None, + }, + &digest, + "snapshot-digest", + ); + + assert_eq!(trace.terminal_state, ProposalTerminalState::Succeeded); + assert_eq!(trace.node_outcomes.len(), 1); + } + + #[test] + fn pointer_set_with_an_empty_pointer_replaces_the_entire_target() { + let mut target = json!({"unused": true}); + pointer_set(&mut target, "", json!({"replaced": true})); + assert_eq!(target, json!({"replaced": true})); + } +} diff --git a/docs/workflow-proposal-lifecycle.md b/docs/workflow-proposal-lifecycle.md new file mode 100644 index 00000000..ae8e4cdd --- /dev/null +++ b/docs/workflow-proposal-lifecycle.md @@ -0,0 +1,188 @@ +# Runtime Workflow Proposal Lifecycle (P1) + +Governed by spec [`109-runtime-workflow-proposals`](../specs/109-runtime-workflow-proposals/spec.md) +and [ADR-0041](adr/0041-governed-runtime-workflow-proposal-authority.md). Tracks +issue `#1090`. + +A **workflow proposal** is an untrusted, externally-authored, ephemeral, +manifest-bound bounded sequential DAG over already-registered capabilities. +An MCP client (or any planner) submits one; Traverse validates, authorizes, +and executes it — the planner is never the authority (ADR-0041). + +## Where the code lives + +| Layer | Crate | What it owns | +|---|---|---| +| Wire format, canonicalization, digesting, structural validation | `traverse-contracts::proposal` | Pure, no manifest/registry access — a proposer can independently recompute the same digest from the same JSON. | +| Manifest/registry cross-validation, authorization, quotas, execution | `traverse-runtime::proposal` | Everything needing live host state: `traverse_registry::ApplicationBundleManifest`, `CapabilityRegistry`, and the `Runtime` execution engine. | +| Public MCP tool surface | `traverse-mcp::tools::proposals` | Plain, fully-tested Rust functions — the same pattern `tools::capabilities` already established for spec `015`. Not wired into `stdio_server.rs`; that reference host is a separate, single-example-bundle transport (see [docs/browser-hosted-execute-entrypoint-validation.md](browser-hosted-execute-entrypoint-validation.md)), not this spec's governed surface. | + +## Wire format + +```json +{ + "kind": "workflow_proposal", + "schema_version": "1.0.0", + "proposal_id": "proposal-001", + "workspace_id": "workspace-001", + "app_manifest": { "app_id": "...", "app_version": "1.0.0", "manifest_digest": "sha256:..." }, + "nodes": [ + { "node_id": "a", "capability_id": "content.comments.create-comment-draft", "capability_version": "1.0.0", "artifact_digest": "sha256:..." } + ], + "edges": [ { "from_node_id": "a", "to_node_id": "b" } ], + "mappings": [ + { "source": { "kind": "node", "node_id": "a" }, "source_path": "/draft_id", "target_node_id": "b", "target_path": "/draft_id" }, + { "source": { "kind": "initial_input" }, "source_path": "/comment_text", "target_node_id": "a", "target_path": "/comment_text" } + ], + "initial_input": { "comment_text": "hello", "resource_id": "r1" } +} +``` + +Every field the runtime authorizes or executes against is explicit here — a +node never implicitly sees another node's full output; every path is a +declared mapping (spec FR-002). + +## Canonicalization, digesting, and snapshot binding (FR-003, FR-007a) + +- **Structural validation** (`canonicalize_proposal`) rejects, before any host + lookup: wrong `kind`/`schema_version`, over-limit node/edge/mapping counts + or `initial_input` byte size, duplicate node ids, dangling or duplicate + edges, self-loops, cycles, mappings to/from unknown endpoints, a mapping + with no corresponding declared edge, and an ambiguous multi-writer target + path (two mappings writing the same `target_path` on the same node). +- **Deterministic execution order**: Kahn's algorithm over the declared + edges, breaking ties among simultaneously-ready nodes by lexicographic + `node_id` (FR-007a). A diamond graph (`a → b`, `a → c`, `b → d`, `c → d`) + always orders as `a, b, c, d`, never `a, c, b, d`. +- **`proposal_digest`**: the proposal is serialized to **canonical JSON** + (object keys recursively sorted, no insignificant whitespace — a from- + scratch implementation; no such helper existed anywhere in the workspace + before this) and hashed with SHA-256, formatted as + `"1.0.0:sha256:"`. This is deliberately **not** the `governed_content_digest` + convention used for published capability/event contracts (`{version}:{fnv1a-hex}` + over Rust `Debug` output) — that scheme is for "did this contract's Rust + struct change" and is neither cryptographically strong nor independently + reproducible from raw JSON by an external party. A digest that an approval + token binds to for authorization needs both properties. +- **`proposal_snapshot_digest`**: hashes `proposal_digest` together with the + manifest/registry/binding/policy/budget digests supplied by the caller + (`SnapshotDigests`). This is the digest an approval token is actually + scoped to (ADR-0041's "governing snapshots") — it changes if any pinned + input changes even when the proposal JSON is byte-identical. + +## Cross-validation (FR-004, FR-011) + +`validate_proposal_against_host_state` runs after structural validation and +requires a loaded `ApplicationBundleManifest` and `CapabilityRegistry`: + +1. **Declared capability set**: every node's `capability_id@capability_version` + must appear in `manifest.components` — mirrors the existing "declared set + is the only permitted set" pattern already used for manifest connector + bindings in `traverse-cli`'s `app_activate_at`. +2. **Exact artifact pinning**: the registry's resolved artifact digest + (`binary_digest`, falling back to `source_digest`) must equal the node's + declared `artifact_digest`. +3. **Mapping schema compatibility**: source/target JSON Schema fragments at + each mapping's path are resolved by walking `properties`/`items` + segments; if both declare a `type`, it must match. This is intentionally + bounded — not full JSON Schema validation — matching how this codebase + already treats `inputs`/`outputs` schemas as documentation-grade shape, + not a validator target. +4. **Field-level data-flow policy** (FR-011 — spec text names the rule but + not byte-level semantics; this is this issue's concrete, documented + interpretation): for a mapping from node A's output path to node B's + input path, + - A's `risk.data_flow.produced_data_classifications` **must** declare a + classification at that exact path — an *undeclared* classification is + never treated as safe (fail closed; this is what "schema compatibility + alone MUST NOT authorize disclosure" means in practice). + - B's `risk.data_flow.accepted_data_classifications` **must** declare a + classification at the target path, and it must be `>=` what A + produces (the ordering is `Public < Internal < Confidential < + Restricted`, added to `DataClassification` for exactly this + comparison). + - If the produced classification is above `Public` and B's + `effect_class` is `external_effect`/`irreversible_effect`, B's + `egress_policy` must not be `Denied` — classified data cannot flow + into a capability with zero declared legitimate egress surface. + - Mappings sourced from `initial_input` are exempt from classification + checks: FR-011 governs capability-to-capability data flow, not + caller-supplied input, which has no capability-declared classification + to check against. + +## Authorization (FR-006, FR-006a) + +`proposal_is_automatic_eligible` is the exact same +`traverse_contracts::is_automatic_eligible` check from spec `109`'s FR-005 +risk-metadata work (issue `#1091`), applied to every resolved node — a +proposal is automatic-eligible only if **every** node is. The moment one node +is not (state-write, external/irreversible effect, non-deterministic, or +requires idempotency), the whole proposal requires a verified approval +token. + +**Approval tokens** are Ed25519-signed, JWT-shaped tokens (`header.payload.signature`, +base64url, `alg` restricted to `EdDSA` — the same discipline as this repo's +existing HTTP bearer-token verification in `traverse-cli::http_api`, but a +parallel implementation rather than shared code: that code is hard-wired to +one global verification key and an HTTP-specific identity shape, with no +`kid`/multi-key, audience, or digest-binding concept to extend). Claims: + +| Claim | Meaning | +|---|---| +| `jti` | Token id — the replay/use-count/revocation key. | +| `iss`, `aud` | Verified against host-configured expected values. | +| `sub` | The approving principal. | +| `workspace_id`, `proposal_digest`, `snapshot_digest` | Exact binding — any mismatch is rejected. | +| `permitted_effects`, `permitted_connectors` | Advisory scope hints carried through to the trace. | +| `max_use_count`, `exp` | Enforced by `ApprovalTokenStore` and time-claim comparison. | + +`ApprovalTokenStore` is an in-memory, per-process ledger of use-count and +revocation state, keyed by `jti`. Tokens are short-lived and scoped to one +pinned snapshot, so no persistence across restarts is needed. + +**This repo verifies approval tokens; it does not issue them.** Per +ADR-0041, the approving principal/service is external to Traverse — there is +no token-signing code here, only verification. + +## Quotas (FR-007b) + +`QuotaTracker` enforces independent concurrency ceilings per principal, app, +and workspace (`QuotaLimits`, default 4/16/32 concurrent executions). A +reservation is an RAII guard (`QuotaReservation`) that releases automatically +on drop, so a slot can never leak on an early return. + +## Execution and trace (FR-007, FR-008, FR-008a, FR-009) + +`execute_proposal` runs nodes one at a time in the canonicalized order. +Before each node, its input is assembled **solely** from the proposal's +declared mappings (JSON-Pointer get/set against `initial_input` and prior +nodes' outputs — no implicit full-output passthrough), then dispatched +through the existing `Runtime::execute` single-capability path (not the +branching/event-driven workflow engine in `workflows.rs`, which has fan-out, +event-wait, and state-merge semantics P1 explicitly excludes). The first +failed node stops execution; remaining nodes are marked +`skipped_after_earlier_failure` — no retry, no compensation, no graph or +catalog mutation. + +The resulting `ProposalTrace` is deliberately narrow: proposal/snapshot +digests, the authorization summary (automatic vs. approval-token id — never +the raw token), per-node status, and mapping **paths** (never mapped +*values*). No raw input/output payloads, no secrets. `observe_proposal` +renders it directly to JSON for MCP consumption. + +## MCP tool surface + +`traverse_mcp::tools::proposals` exposes: `validate_proposal`, +`submit_proposal`, `authorization_state`, `execute_proposal_via_mcp`, +`observe_proposal`, `export_proposal`. A structurally or semantically invalid +proposal, a missing/invalid approval token, and an exhausted quota are all +**normal structured responses** (`valid: false` / `AuthorizationState::Invalid` +/ `ProposalExecutionResponse::Denied { code, message }`), never an `McpError` +— `McpError` is reserved for input that isn't even parseable JSON. Every +denial code is a stable `snake_case` string (FR-010). + +## Non-goals (unchanged from spec 109) + +Parallel execution, cycles, event waits, durable resume, automatic retries, +sagas, direct registry mutation, and planner implementation remain out of +scope for P1, matching the spec's own "Out of scope" section.