//! O5 — Three operation surfaces (MCP / HTTP / CLI) converge on the //! same dispatcher with equivalent witnesses. //! //! ADR-024 requires the surfaces to be interchangeable. This test //! exercises each path against the same `(op_id, inputs)` and asserts //! the resulting witnesses match on every cross-surface invariant //! (op_id, witness_shape, state_root, signature, effects). //! //! - HTTP: in-process axum via `tower::ServiceExt::oneshot` — no //! live socket required. //! - CLI: spawns the compiled daemon binary via //! `env!("CARGO_BIN_EXE_ontoref-daemon")`. //! - MCP: calls the pure-function projection that the MCP `tools/list` //! response is built from. use std::process::Command; use ontoref_daemon::ops_surface::{ dispatch_op_request, ops_as_mcp_tools, ContextOverride, DispatchRequest, DispatchResponse, }; fn deterministic_seed_hex() -> String { // 32 bytes of 0x42 — matches OpContext piloto default. "42".repeat(32) } fn sample_propose_adr_request(adr_id: &str, project_root: std::path::PathBuf) -> DispatchRequest { DispatchRequest { inputs: serde_json::json!({ "id": adr_id, "slug": "surface-equivalence", "title": "Surface equivalence test", "decision": "All three surfaces dispatch the same op.", "ondaod_evaluation": { "engaged_tensions": ["ontology-vs-reflection"], "synthesis_state": "Realized", "motion_direction": "Static" } }), context: Some(ContextOverride { actor_id: Some("developer".to_owned()), signing_key_seed_hex: Some(deterministic_seed_hex()), project_root: Some(project_root), ..Default::default() }), } } #[test] fn convergence_function_dispatches_propose_adr() { let tmp = tempfile::tempdir().expect("tempdir"); let request = sample_propose_adr_request("adr-501", tmp.path().to_path_buf()); let response = dispatch_op_request("propose_adr", request).expect("dispatch ok"); assert_eq!(response.op_id, "propose_adr"); assert_eq!(response.witness_shape, "adr_proposed"); assert_eq!(response.state_root_hex.len(), 64); assert_eq!(response.signature_hex.len(), 128); assert_eq!(response.actor_public_key_hex.len(), 64); // ADR file was actually written. let adr_path = tmp .path() .join(".ontoref/adrs/adr-501-surface-equivalence.ncl"); assert!(adr_path.exists(), "ADR file must exist after dispatch"); } #[tokio::test] async fn http_surface_dispatches_propose_adr() { use axum::body::Body; use axum::http::Request; use tower::ServiceExt as _; let tmp = tempfile::tempdir().expect("tempdir"); // Build a minimal axum router with only the /ops route — sidesteps // the daemon's AppState scaffolding (UI, db, NATS) entirely. let app = axum::Router::new().route( "/ops/{op_id}", axum::routing::post(test_handlers::ops_dispatch_handler), ); let request = sample_propose_adr_request("adr-502", tmp.path().to_path_buf()); let body_json = serde_json::to_string(&serde_json::json!({ "inputs": request.inputs, "context": { "actor_id": "developer", "signing_key_seed_hex": deterministic_seed_hex(), "project_root": tmp.path().to_string_lossy(), } })) .expect("serialise body"); let response = app .oneshot( Request::builder() .method("POST") .uri("/ops/propose_adr") .header("content-type", "application/json") .body(Body::from(body_json)) .unwrap(), ) .await .expect("oneshot ok"); assert_eq!(response.status(), axum::http::StatusCode::OK); let body_bytes = axum::body::to_bytes(response.into_body(), 16 * 1024) .await .expect("collect body"); let response: DispatchResponse = serde_json::from_slice(&body_bytes).expect("response parses"); assert_eq!(response.op_id, "propose_adr"); assert_eq!(response.witness_shape, "adr_proposed"); assert!(tmp.path().join(".ontoref/adrs/adr-502-surface-equivalence.ncl").exists()); } #[test] fn cli_surface_dispatches_propose_adr() { let tmp = tempfile::tempdir().expect("tempdir"); let inputs = serde_json::json!({ "id": "adr-503", "slug": "cli-test", "title": "CLI test", "decision": "CLI surface produces an equivalent witness.", "ondaod_evaluation": { "engaged_tensions": ["ontology-vs-reflection"], "synthesis_state": "Realized", "motion_direction": "Static" } }); let context = serde_json::json!({ "actor_id": "developer", "signing_key_seed_hex": deterministic_seed_hex(), "project_root": tmp.path().to_string_lossy(), }); let bin = env!("CARGO_BIN_EXE_ontoref-daemon"); let output = Command::new(bin) .arg("--invoke-op") .arg("propose_adr") .arg("--invoke-inputs") .arg(inputs.to_string()) .arg("--invoke-context") .arg(context.to_string()) .output() .expect("run daemon binary"); assert!( output.status.success(), "daemon --invoke-op exited non-zero: stderr={}", String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).expect("utf-8 stdout"); let response: DispatchResponse = serde_json::from_str(&stdout).expect("stdout parses as DispatchResponse"); assert_eq!(response.op_id, "propose_adr"); assert!(tmp.path().join(".ontoref/adrs/adr-503-cli-test.ncl").exists()); } #[test] fn mcp_tool_list_includes_pilot_ops() { let tools = ops_as_mcp_tools(); let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect(); for required in [ "propose_adr", "move_fsm_state", "update_ontology_node", "manage_backlog_item", ] { assert!( names.contains(&required), "MCP tool list must include '{required}'; got {names:?}" ); } } #[test] fn function_cli_and_http_produce_equivalent_witnesses() { // Run the same propose_adr request through the function surface // and through the CLI; the two witnesses must match on // (op_id, witness_shape, state_root, signature, effects_hash). let tmp_fn = tempfile::tempdir().expect("tempdir"); let tmp_cli = tempfile::tempdir().expect("tempdir"); let fn_request = sample_propose_adr_request("adr-510", tmp_fn.path().to_path_buf()); let fn_response = dispatch_op_request("propose_adr", fn_request).expect("fn dispatch"); let cli_inputs = serde_json::json!({ "id": "adr-510", "slug": "surface-equivalence", "title": "Surface equivalence test", "decision": "All three surfaces dispatch the same op.", "ondaod_evaluation": { "engaged_tensions": ["ontology-vs-reflection"], "synthesis_state": "Realized", "motion_direction": "Static" } }); let cli_context = serde_json::json!({ "actor_id": "developer", "signing_key_seed_hex": deterministic_seed_hex(), "project_root": tmp_cli.path().to_string_lossy(), }); let bin = env!("CARGO_BIN_EXE_ontoref-daemon"); let output = Command::new(bin) .arg("--invoke-op") .arg("propose_adr") .arg("--invoke-inputs") .arg(cli_inputs.to_string()) .arg("--invoke-context") .arg(cli_context.to_string()) .output() .expect("run daemon"); assert!(output.status.success()); let cli_response: DispatchResponse = serde_json::from_slice(&output.stdout).expect("cli stdout parses"); // (op_id, witness_shape, state_root_hex, signature_hex, effects_hash_hex) // are content-determined: same inputs + same signing key + same op // body MUST yield the same witness across surfaces. assert_eq!(fn_response.op_id, cli_response.op_id); assert_eq!(fn_response.witness_shape, cli_response.witness_shape); assert_eq!(fn_response.state_root_hex, cli_response.state_root_hex); assert_eq!(fn_response.signature_hex, cli_response.signature_hex); assert_eq!(fn_response.effects_hash_hex, cli_response.effects_hash_hex); assert_eq!(fn_response.actor_public_key_hex, cli_response.actor_public_key_hex); } /// Inline copies of the daemon's HTTP handlers — strip the AppState /// scaffolding so the test can exercise the route without booting the /// full server. The bodies are deliberately identical to /// `crate::api::ops_dispatch_handler`. mod test_handlers { use axum::extract::Path; use axum::http::StatusCode; use axum::Json; use ontoref_daemon::ops_surface::{dispatch_op_request, DispatchRequest, DispatchResponse}; pub async fn ops_dispatch_handler( Path(op_id): Path, Json(request): Json, ) -> Result, (StatusCode, Json)> { match dispatch_op_request(&op_id, request) { Ok(response) => Ok(Json(response)), Err(e) => Err(( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": e.to_string(), "op_id": op_id })), )), } } }