1use crate::error::{Error, Result};
2use crate::output::{print_success, OutputFormat};
3use crate::tap_integration::TapIntegration;
4use clap::Subcommand;
5use serde::Serialize;
6use serde_json::Value;
7use tap_node::storage::{DecisionStatus, DecisionType};
8use tracing::debug;
9
10#[derive(Subcommand, Debug)]
11pub enum DecisionCommands {
12 #[command(long_about = "\
14List decisions from the decision log.
15
16Decisions are created when the TAP node reaches a decision point in the \
17transaction lifecycle (e.g., a transfer needs authorization, or a transaction \
18is ready for settlement). In poll mode (--decision-mode poll on tap-http), \
19decisions accumulate in the database for external systems to act on.
20
21Decision types:
22 authorization_required A new transaction needs approval
23 policy_satisfaction_required Policies must be fulfilled before proceeding
24 settlement_required All agents authorized, ready to settle
25
26Decision statuses:
27 pending Written to DB, not yet acted upon
28 delivered Sent to external process, awaiting action
29 resolved Action taken (authorize, reject, settle, etc.)
30 expired Transaction reached terminal state before resolution
31
32Examples:
33 # List all pending decisions
34 tap-cli decision list --status pending
35
36 # List all decisions (any status)
37 tap-cli decision list
38
39 # Paginate through decisions
40 tap-cli decision list --since-id 100 --limit 20
41
42 # List decisions for a specific agent
43 tap-cli decision list --agent-did did:key:z6Mk...")]
44 List {
45 #[arg(long)]
47 agent_did: Option<String>,
48 #[arg(long)]
50 status: Option<String>,
51 #[arg(long)]
53 since_id: Option<i64>,
54 #[arg(long, default_value = "50")]
56 limit: u32,
57 },
58 #[command(long_about = "\
60Resolve a pending decision by specifying the action to take.
61
62This marks the decision as resolved in the decision log. Only decisions \
63with status 'pending' or 'delivered' can be resolved.
64
65Valid actions per decision type:
66 authorization_required: authorize, reject, update_policies, defer
67 policy_satisfaction_required: present, reject, cancel, defer
68 settlement_required: settle, cancel, defer
69
70The 'defer' action marks the decision as delivered rather than resolved, \
71indicating you've seen it but will act later.
72
73Note: This command only updates the decision log. To actually send the \
74corresponding TAP message (e.g., Authorize), use the 'action' commands:
75 tap-cli action authorize --transaction-id <ID>
76 tap-cli action reject --transaction-id <ID> --reason <TEXT>
77 tap-cli action settle --transaction-id <ID> --settlement-id <CAIP-220>
78
79The action commands automatically resolve matching decisions when they succeed.
80
81Examples:
82 # Resolve a decision by authorizing the transaction
83 tap-cli decision resolve --decision-id 42 --action authorize
84
85 # Resolve with additional detail
86 tap-cli decision resolve --decision-id 42 --action authorize \\
87 --detail '{\"settlement_address\":\"eip155:1:0xABC\"}'
88
89 # Reject a decision
90 tap-cli decision resolve --decision-id 42 --action reject
91
92 # Defer a decision (mark as seen, act later)
93 tap-cli decision resolve --decision-id 42 --action defer")]
94 Resolve {
95 #[arg(long)]
97 decision_id: i64,
98 #[arg(long)]
100 action: String,
101 #[arg(long)]
103 agent_did: Option<String>,
104 #[arg(long)]
106 detail: Option<String>,
107 },
108}
109
110#[derive(Debug, Serialize)]
111struct DecisionInfo {
112 id: i64,
113 transaction_id: String,
114 agent_did: String,
115 decision_type: String,
116 context: Value,
117 status: String,
118 resolution: Option<String>,
119 resolution_detail: Option<Value>,
120 created_at: String,
121 delivered_at: Option<String>,
122 resolved_at: Option<String>,
123}
124
125#[derive(Debug, Serialize)]
126struct DecisionListResponse {
127 decisions: Vec<DecisionInfo>,
128 total: usize,
129}
130
131#[derive(Debug, Serialize)]
132struct DecisionResolveResponse {
133 decision_id: i64,
134 transaction_id: String,
135 status: String,
136 action: String,
137 resolved_at: String,
138}
139
140pub async fn handle(
141 cmd: &DecisionCommands,
142 format: OutputFormat,
143 default_agent_did: &str,
144 tap_integration: &TapIntegration,
145) -> Result<()> {
146 match cmd {
147 DecisionCommands::List {
148 agent_did,
149 status,
150 since_id,
151 limit,
152 } => {
153 let effective_did = agent_did.as_deref().unwrap_or(default_agent_did);
154 let storage = tap_integration.storage_for_agent(effective_did).await?;
155
156 let status_filter = status
157 .as_deref()
158 .map(DecisionStatus::try_from)
159 .transpose()
160 .map_err(|e| Error::invalid_parameter(format!("Invalid status: {}", e)))?;
161
162 let entries = storage
163 .list_decisions(Some(effective_did), status_filter, *since_id, *limit)
164 .await?;
165
166 let decisions: Vec<DecisionInfo> = entries
167 .into_iter()
168 .map(|e| DecisionInfo {
169 id: e.id,
170 transaction_id: e.transaction_id,
171 agent_did: e.agent_did,
172 decision_type: e.decision_type.to_string(),
173 context: e.context_json,
174 status: e.status.to_string(),
175 resolution: e.resolution,
176 resolution_detail: e.resolution_detail,
177 created_at: e.created_at,
178 delivered_at: e.delivered_at,
179 resolved_at: e.resolved_at,
180 })
181 .collect();
182
183 let response = DecisionListResponse {
184 total: decisions.len(),
185 decisions,
186 };
187 print_success(format, &response);
188 Ok(())
189 }
190 DecisionCommands::Resolve {
191 decision_id,
192 action,
193 agent_did,
194 detail,
195 } => {
196 let effective_did = agent_did.as_deref().unwrap_or(default_agent_did);
197 let storage = tap_integration.storage_for_agent(effective_did).await?;
198
199 let detail_value: Option<Value> = match detail {
200 Some(d) => Some(serde_json::from_str(d).map_err(|e| {
201 Error::invalid_parameter(format!("Invalid JSON in --detail: {}", e))
202 })?),
203 None => None,
204 };
205
206 let entry = storage
208 .get_decision_by_id(*decision_id)
209 .await?
210 .ok_or_else(|| {
211 Error::command_failed(format!("Decision {} not found", decision_id))
212 })?;
213
214 if entry.status != DecisionStatus::Pending && entry.status != DecisionStatus::Delivered
215 {
216 return Err(Error::command_failed(format!(
217 "Decision {} is already {} and cannot be resolved",
218 decision_id, entry.status
219 )));
220 }
221
222 debug!("Resolving decision {} with action: {}", decision_id, action);
223
224 storage
225 .update_decision_status(
226 *decision_id,
227 DecisionStatus::Resolved,
228 Some(action),
229 detail_value.as_ref(),
230 )
231 .await?;
232
233 let response = DecisionResolveResponse {
234 decision_id: *decision_id,
235 transaction_id: entry.transaction_id,
236 status: "resolved".to_string(),
237 action: action.clone(),
238 resolved_at: chrono::Utc::now().to_rfc3339(),
239 };
240 print_success(format, &response);
241 Ok(())
242 }
243 }
244}
245
246pub async fn auto_resolve_decisions(
252 tap_integration: &TapIntegration,
253 agent_did: &str,
254 transaction_id: &str,
255 action: &str,
256 decision_type: Option<DecisionType>,
257) {
258 if let Ok(storage) = tap_integration.storage_for_agent(agent_did).await {
259 match storage
260 .resolve_decisions_for_transaction(transaction_id, action, decision_type)
261 .await
262 {
263 Ok(count) => {
264 if count > 0 {
265 debug!(
266 "Auto-resolved {} decisions for transaction {} with action: {}",
267 count, transaction_id, action
268 );
269 }
270 }
271 Err(e) => {
272 debug!(
273 "Could not auto-resolve decisions for transaction {}: {}",
274 transaction_id, e
275 );
276 }
277 }
278 }
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284 use crate::tap_integration::TapIntegration;
285 use serde_json::json;
286 use tempfile::tempdir;
287
288 async fn setup_test() -> (TapIntegration, String) {
289 let dir = tempdir().unwrap();
290 let tap_root = dir.path().to_str().unwrap();
291
292 let (agent, did) = tap_agent::TapAgent::from_ephemeral_key().await.unwrap();
293 let agent_arc = std::sync::Arc::new(agent);
294
295 let integration = TapIntegration::new(Some(&did), Some(tap_root), Some(agent_arc))
296 .await
297 .unwrap();
298
299 std::mem::forget(dir);
300 (integration, did)
301 }
302
303 #[tokio::test]
304 async fn test_decision_list_empty() {
305 let (integration, did) = setup_test().await;
306
307 let cmd = DecisionCommands::List {
308 agent_did: Some(did.clone()),
309 status: None,
310 since_id: None,
311 limit: 50,
312 };
313
314 let result = handle(&cmd, OutputFormat::Json, &did, &integration).await;
315 assert!(result.is_ok());
316 }
317
318 #[tokio::test]
319 async fn test_decision_list_with_entries() {
320 let (integration, did) = setup_test().await;
321
322 let storage = integration.storage_for_agent(&did).await.unwrap();
323 let context = json!({"transaction": {"type": "transfer", "amount": "100"}});
324 storage
325 .insert_decision(
326 "txn-cli-1",
327 &did,
328 DecisionType::AuthorizationRequired,
329 &context,
330 )
331 .await
332 .unwrap();
333 storage
334 .insert_decision(
335 "txn-cli-2",
336 &did,
337 DecisionType::SettlementRequired,
338 &context,
339 )
340 .await
341 .unwrap();
342
343 let cmd = DecisionCommands::List {
344 agent_did: Some(did.clone()),
345 status: Some("pending".to_string()),
346 since_id: None,
347 limit: 50,
348 };
349
350 let result = handle(&cmd, OutputFormat::Json, &did, &integration).await;
351 assert!(result.is_ok());
352 }
353
354 #[tokio::test]
355 async fn test_decision_list_with_status_filter() {
356 let (integration, did) = setup_test().await;
357
358 let storage = integration.storage_for_agent(&did).await.unwrap();
359 let context = json!({"transaction": {"type": "transfer"}});
360 let id = storage
361 .insert_decision(
362 "txn-cli-3",
363 &did,
364 DecisionType::AuthorizationRequired,
365 &context,
366 )
367 .await
368 .unwrap();
369
370 storage
372 .update_decision_status(id, DecisionStatus::Resolved, Some("authorize"), None)
373 .await
374 .unwrap();
375
376 storage
378 .insert_decision(
379 "txn-cli-4",
380 &did,
381 DecisionType::SettlementRequired,
382 &context,
383 )
384 .await
385 .unwrap();
386
387 let cmd = DecisionCommands::List {
389 agent_did: Some(did.clone()),
390 status: Some("resolved".to_string()),
391 since_id: None,
392 limit: 50,
393 };
394
395 let result = handle(&cmd, OutputFormat::Json, &did, &integration).await;
396 assert!(result.is_ok());
397 }
398
399 #[tokio::test]
400 async fn test_decision_list_invalid_status() {
401 let (integration, did) = setup_test().await;
402
403 let cmd = DecisionCommands::List {
404 agent_did: Some(did.clone()),
405 status: Some("invalid_status".to_string()),
406 since_id: None,
407 limit: 50,
408 };
409
410 let result = handle(&cmd, OutputFormat::Json, &did, &integration).await;
411 assert!(result.is_err());
412 }
413
414 #[tokio::test]
415 async fn test_decision_resolve_success() {
416 let (integration, did) = setup_test().await;
417
418 let storage = integration.storage_for_agent(&did).await.unwrap();
419 let context = json!({"transaction": {"type": "transfer"}});
420 let decision_id = storage
421 .insert_decision(
422 "txn-cli-10",
423 &did,
424 DecisionType::AuthorizationRequired,
425 &context,
426 )
427 .await
428 .unwrap();
429
430 let cmd = DecisionCommands::Resolve {
431 decision_id,
432 action: "authorize".to_string(),
433 agent_did: Some(did.clone()),
434 detail: Some(r#"{"settlement_address":"eip155:1:0xABC"}"#.to_string()),
435 };
436
437 let result = handle(&cmd, OutputFormat::Json, &did, &integration).await;
438 assert!(result.is_ok());
439
440 let entry = storage
442 .get_decision_by_id(decision_id)
443 .await
444 .unwrap()
445 .unwrap();
446 assert_eq!(entry.status, DecisionStatus::Resolved);
447 assert_eq!(entry.resolution.as_deref(), Some("authorize"));
448 }
449
450 #[tokio::test]
451 async fn test_decision_resolve_not_found() {
452 let (integration, did) = setup_test().await;
453
454 let cmd = DecisionCommands::Resolve {
455 decision_id: 99999,
456 action: "authorize".to_string(),
457 agent_did: Some(did.clone()),
458 detail: None,
459 };
460
461 let result = handle(&cmd, OutputFormat::Json, &did, &integration).await;
462 assert!(result.is_err());
463 }
464
465 #[tokio::test]
466 async fn test_decision_resolve_already_resolved() {
467 let (integration, did) = setup_test().await;
468
469 let storage = integration.storage_for_agent(&did).await.unwrap();
470 let context = json!({"transaction": {"type": "transfer"}});
471 let decision_id = storage
472 .insert_decision(
473 "txn-cli-11",
474 &did,
475 DecisionType::AuthorizationRequired,
476 &context,
477 )
478 .await
479 .unwrap();
480
481 storage
483 .update_decision_status(decision_id, DecisionStatus::Resolved, Some("reject"), None)
484 .await
485 .unwrap();
486
487 let cmd = DecisionCommands::Resolve {
488 decision_id,
489 action: "authorize".to_string(),
490 agent_did: Some(did.clone()),
491 detail: None,
492 };
493
494 let result = handle(&cmd, OutputFormat::Json, &did, &integration).await;
495 assert!(result.is_err());
496 }
497
498 #[tokio::test]
499 async fn test_decision_resolve_invalid_detail_json() {
500 let (integration, did) = setup_test().await;
501
502 let storage = integration.storage_for_agent(&did).await.unwrap();
503 let context = json!({"transaction": {"type": "transfer"}});
504 let decision_id = storage
505 .insert_decision(
506 "txn-cli-12",
507 &did,
508 DecisionType::AuthorizationRequired,
509 &context,
510 )
511 .await
512 .unwrap();
513
514 let cmd = DecisionCommands::Resolve {
515 decision_id,
516 action: "authorize".to_string(),
517 agent_did: Some(did.clone()),
518 detail: Some("not valid json".to_string()),
519 };
520
521 let result = handle(&cmd, OutputFormat::Json, &did, &integration).await;
522 assert!(result.is_err());
523 }
524
525 #[tokio::test]
526 async fn test_auto_resolve_decisions() {
527 let (integration, did) = setup_test().await;
528
529 let storage = integration.storage_for_agent(&did).await.unwrap();
530 let context = json!({"transaction": {"type": "transfer"}});
531 let decision_id = storage
532 .insert_decision(
533 "txn-cli-20",
534 &did,
535 DecisionType::AuthorizationRequired,
536 &context,
537 )
538 .await
539 .unwrap();
540
541 super::auto_resolve_decisions(
543 &integration,
544 &did,
545 "txn-cli-20",
546 "authorize",
547 Some(DecisionType::AuthorizationRequired),
548 )
549 .await;
550
551 let entry = storage
552 .get_decision_by_id(decision_id)
553 .await
554 .unwrap()
555 .unwrap();
556 assert_eq!(entry.status, DecisionStatus::Resolved);
557 assert_eq!(entry.resolution.as_deref(), Some("authorize"));
558 }
559}