Skip to main content

tap_cli/commands/
decision.rs

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    /// List decisions from the decision log
13    #[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        /// Agent DID for storage lookup (defaults to --agent-did global flag)
46        #[arg(long)]
47        agent_did: Option<String>,
48        /// Filter by status: pending, delivered, resolved, expired
49        #[arg(long)]
50        status: Option<String>,
51        /// Only return decisions with ID greater than this value (for pagination)
52        #[arg(long)]
53        since_id: Option<i64>,
54        /// Maximum results
55        #[arg(long, default_value = "50")]
56        limit: u32,
57    },
58    /// Resolve a pending decision by specifying the action to take
59    #[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        /// Decision ID to resolve (numeric, from 'decision list' output)
96        #[arg(long)]
97        decision_id: i64,
98        /// Action to take: authorize, reject, settle, cancel, present, defer, update_policies
99        #[arg(long)]
100        action: String,
101        /// Agent DID for storage lookup (defaults to --agent-did global flag)
102        #[arg(long)]
103        agent_did: Option<String>,
104        /// Optional JSON detail about the resolution (e.g., settlement_address, reason)
105        #[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            // Verify the decision exists and is actionable
207            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
246/// Resolve decisions in the decision_log after a successful action.
247///
248/// When an action command (authorize, reject, settle, cancel, revert) succeeds,
249/// this function resolves matching pending/delivered decisions in the shared
250/// database, matching the behavior of tap-mcp's auto-resolve.
251pub 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        // Resolve one
371        storage
372            .update_decision_status(id, DecisionStatus::Resolved, Some("authorize"), None)
373            .await
374            .unwrap();
375
376        // Insert another that stays pending
377        storage
378            .insert_decision(
379                "txn-cli-4",
380                &did,
381                DecisionType::SettlementRequired,
382                &context,
383            )
384            .await
385            .unwrap();
386
387        // List only resolved
388        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        // Verify in storage
441        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        // Resolve it first
482        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        // Auto-resolve
542        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}