1use std::time::Duration;
8
9use serde::Deserialize;
10
11use crate::config::VerificationRule;
12use crate::dispatch;
13use crate::pb::pilot::{CapabilityCall, CapabilityCallResult};
14use crate::plan_runtime::PlanRuntime;
15use crate::rtdl_wire::NodeEventContext;
16use robonix_atlas::client::AtlasClient;
17use robonix_scribe::{info, warn};
18
19pub const VERIFY_CONTRACT_ID: &str = "robonix/service/verifier/verify";
20const VERIFICATION_TIMEOUT: Duration = Duration::from_secs(60);
21
22#[derive(Clone, Debug, Default)]
24pub struct VerificationPolicy {
25 rules: Vec<VerificationRule>,
26}
27
28impl VerificationPolicy {
29 pub fn new(rules: Vec<VerificationRule>) -> Self {
30 Self { rules }
31 }
32
33 pub fn len(&self) -> usize {
34 self.rules.len()
35 }
36
37 pub fn rule_for(&self, call: &CapabilityCall) -> Option<&VerificationRule> {
39 self.rules
40 .iter()
41 .find(|rule| {
42 rule.target_contract_id == call.contract_id
43 && rule.target_provider_id.as_deref() == Some(call.provider_id.as_str())
44 })
45 .or_else(|| {
46 self.rules.iter().find(|rule| {
47 rule.target_contract_id == call.contract_id && rule.target_provider_id.is_none()
48 })
49 })
50 }
51}
52
53#[derive(Debug, Deserialize)]
54struct VerifyResponse {
55 passed: bool,
56 detail: String,
57}
58
59pub async fn verify_result(
62 policy: &VerificationPolicy,
63 call: &CapabilityCall,
64 node: &NodeEventContext,
65 original: CapabilityCallResult,
66 self_provider_id: &str,
67 atlas: &mut AtlasClient,
68 runtime: &PlanRuntime,
69) -> CapabilityCallResult {
70 let Some(rule) = policy.rule_for(call) else {
71 return original;
72 };
73
74 let verifier_call = build_verifier_call(rule, call, &node.description, &original);
75 info!(
76 "[executor/verification] call_id={} target='{}' verifier='{}'",
77 call.call_id, call.contract_id, rule.verifier_provider_id
78 );
79 let response = dispatch::dispatch_with_timeout(
80 &verifier_call,
81 self_provider_id,
82 atlas,
83 runtime,
84 &node.plan_id,
85 Some(VERIFICATION_TIMEOUT),
86 )
87 .await;
88 if !response.success {
89 return unavailable(
90 original,
91 format!(
92 "verifier '{}' failed: {}",
93 rule.verifier_provider_id, response.error
94 ),
95 );
96 }
97
98 let parsed: VerifyResponse = match serde_json::from_str(&response.output) {
99 Ok(parsed) => parsed,
100 Err(error) => {
101 return unavailable(
102 original,
103 format!(
104 "verifier '{}' returned invalid response: {error}",
105 rule.verifier_provider_id
106 ),
107 );
108 }
109 };
110 apply_verdict(original, parsed)
111}
112
113fn build_verifier_call(
116 rule: &VerificationRule,
117 call: &CapabilityCall,
118 description: &str,
119 result: &CapabilityCallResult,
120) -> CapabilityCall {
121 let payload = serde_json::json!({
122 "target_provider_id": call.provider_id,
123 "target_contract_id": call.contract_id,
124 "target_description": description,
125 "target_args": json_or_string(&call.args_json),
126 "target_output": json_or_string(&result.output),
127 "verifier_args": rule.verifier_args,
128 });
129 CapabilityCall {
130 call_id: format!("{}:verify", call.call_id),
131 provider_id: rule.verifier_provider_id.clone(),
132 contract_id: VERIFY_CONTRACT_ID.to_string(),
133 args_json: serde_json::json!({
134 "call_id": call.call_id,
135 "args_json": payload.to_string(),
136 })
137 .to_string(),
138 }
139}
140
141fn json_or_string(raw: &str) -> serde_json::Value {
142 serde_json::from_str(raw).unwrap_or_else(|_| serde_json::Value::String(raw.to_string()))
143}
144
145fn apply_verdict(
146 mut original: CapabilityCallResult,
147 verdict: VerifyResponse,
148) -> CapabilityCallResult {
149 if verdict.passed {
150 info!(
151 "[executor/verification] call_id={} passed: {}",
152 original.call_id, verdict.detail
153 );
154 return original;
155 }
156 let detail = if verdict.detail.trim().is_empty() {
157 "verifier rejected the capability result".to_string()
158 } else {
159 verdict.detail
160 };
161 warn!(
162 "[executor/verification] call_id={} rejected: {}",
163 original.call_id, detail
164 );
165 original.success = false;
166 original.error = format!("result verification failed: {detail}");
167 original
168}
169
170fn unavailable(mut original: CapabilityCallResult, reason: String) -> CapabilityCallResult {
171 warn!(
172 "[executor/verification] call_id={} unavailable: {}",
173 original.call_id, reason
174 );
175 original.success = false;
176 original.error = format!("result verification unavailable: {reason}");
177 original
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183 use robonix_atlas::service::{AtlasRegistry, serve_atlas};
184 use std::net::SocketAddr;
185 use std::sync::Arc;
186
187 fn rule(provider: Option<&str>, verifier: &str) -> VerificationRule {
188 VerificationRule {
189 target_contract_id: "cap/target".to_string(),
190 target_provider_id: provider.map(str::to_string),
191 verifier_provider_id: verifier.to_string(),
192 verifier_args: serde_json::json!({"camera_provider_id":"front_camera"}),
193 }
194 }
195
196 fn call() -> CapabilityCall {
197 CapabilityCall {
198 call_id: "p:0".to_string(),
199 provider_id: "arm".to_string(),
200 contract_id: "cap/target".to_string(),
201 args_json: r#"{"object":"bottle"}"#.to_string(),
202 }
203 }
204
205 fn result() -> CapabilityCallResult {
206 CapabilityCallResult {
207 call_id: "p:0".to_string(),
208 provider_id: "arm".to_string(),
209 contract_id: "cap/target".to_string(),
210 success: true,
211 output: r#"{"accepted":true}"#.to_string(),
212 error: String::new(),
213 }
214 }
215
216 fn node() -> NodeEventContext {
217 NodeEventContext {
218 plan_id: "p".to_string(),
219 node_index: 0,
220 node_kind: 0,
221 op_id: "op".to_string(),
222 description: "place bottle".to_string(),
223 }
224 }
225
226 fn reserve_address() -> SocketAddr {
227 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("reserve port");
228 listener.local_addr().expect("reserved address")
229 }
230
231 #[test]
232 fn exact_provider_rule_wins_over_contract_fallback() {
233 let policy =
234 VerificationPolicy::new(vec![rule(None, "fallback"), rule(Some("arm"), "exact")]);
235 assert_eq!(
236 policy.rule_for(&call()).unwrap().verifier_provider_id,
237 "exact"
238 );
239 }
240
241 #[test]
242 fn verifier_request_contains_runtime_context_and_opaque_args() {
243 let verify = build_verifier_call(
244 &rule(None, "vlm_verifier"),
245 &call(),
246 "place bottle",
247 &result(),
248 );
249 let outer: serde_json::Value = serde_json::from_str(&verify.args_json).unwrap();
250 let payload: serde_json::Value =
251 serde_json::from_str(outer["args_json"].as_str().unwrap()).unwrap();
252 assert_eq!(verify.contract_id, VERIFY_CONTRACT_ID);
253 assert_eq!(payload["target_args"]["object"], "bottle");
254 assert_eq!(payload["target_output"]["accepted"], true);
255 assert_eq!(payload["target_description"], "place bottle");
256 assert_eq!(
257 payload["verifier_args"]["camera_provider_id"],
258 "front_camera"
259 );
260 }
261
262 #[test]
263 fn rejected_verdict_preserves_output_and_fails_original_call() {
264 let output = result().output;
265 let verified = apply_verdict(
266 result(),
267 VerifyResponse {
268 passed: false,
269 detail: "bottle is outside the box".to_string(),
270 },
271 );
272 assert!(!verified.success);
273 assert_eq!(verified.output, output);
274 assert!(verified.error.contains("result verification failed"));
275 }
276
277 #[test]
278 fn passed_verdict_keeps_original_result_unchanged() {
279 let original = result();
280 let verified = apply_verdict(
281 original.clone(),
282 VerifyResponse {
283 passed: true,
284 detail: "visible".to_string(),
285 },
286 );
287 assert_eq!(verified, original);
288 }
289
290 #[test]
291 fn unavailable_verifier_preserves_output_but_fails_closed() {
292 let output = result().output;
293 let verified = unavailable(result(), "not registered".to_string());
294 assert!(!verified.success);
295 assert_eq!(verified.output, output);
296 assert!(verified.error.contains("result verification unavailable"));
297 }
298
299 #[tokio::test]
300 async fn missing_verifier_provider_fails_closed_over_atlas() {
301 let atlas_addr = reserve_address();
302 let registry = Arc::new(AtlasRegistry::default());
303 let atlas_server = tokio::spawn(serve_atlas(registry, atlas_addr));
304 let mut atlas = AtlasClient::connect_with_retry(
305 format!("http://{atlas_addr}"),
306 50,
307 Duration::from_millis(10),
308 )
309 .await
310 .expect("connect test Atlas");
311 let policy = VerificationPolicy::new(vec![rule(None, "missing_verifier")]);
312 let verified = verify_result(
313 &policy,
314 &call(),
315 &node(),
316 result(),
317 "executor",
318 &mut atlas,
319 &PlanRuntime::default(),
320 )
321 .await;
322 assert!(!verified.success);
323 assert!(verified.error.contains("result verification unavailable"));
324 assert!(verified.error.contains("ConnectCapability failed"));
325 atlas_server.abort();
326 }
327}