diff --git a/.cspell/custom-words.txt b/.cspell/custom-words.txt index c2331676..8d4ff7a7 100644 --- a/.cspell/custom-words.txt +++ b/.cspell/custom-words.txt @@ -109,6 +109,7 @@ multistep Mysten nexi nosetests +Nygard Nuvei objx octicons @@ -147,6 +148,7 @@ ropeproject RPCURL Rulebook screenreaders +sess setlocal sharedpref Shopcider @@ -165,6 +167,7 @@ stretchr superfences Truelayer Trulioo +txns udpa unmarshal viewmodel diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 00000000..8d248068 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,24 @@ +# Gitleaks configuration +# https://github.com/gitleaks/gitleaks + +title = "AP2 Gitleaks Configuration" + +[extend] +useDefault = true + +# Allowlist for false positives +[allowlist] +description = "Allowlist for AP2 data key constants" + +# AP2 data key patterns (namespace identifiers, not secrets) +regexTarget = "match" +regexes = [ + '''ap2\.(risk|mandates|types)\.[A-Za-z]+''', +] + +# Specific paths with data key constants +paths = [ + '''samples/go/pkg/ap2/types/.*\.go''', + '''src/ap2/types/.*\.py''', + '''tests/test_.*\.py''', +] diff --git a/docs/topics/fiduciary-circuit-breaker.md b/docs/topics/fiduciary-circuit-breaker.md new file mode 100644 index 00000000..c1749c60 --- /dev/null +++ b/docs/topics/fiduciary-circuit-breaker.md @@ -0,0 +1,285 @@ +# Fiduciary Circuit Breaker (FCB) Extension + +!!! info + + This extension provides structured risk types for AP2 Section 7.4 (Risk Signals). + + `v0.1-alpha` (see [roadmap](../roadmap.md)) + +## Overview + +The **Fiduciary Circuit Breaker (FCB)** is a runtime governance pattern that complements AP2's mandate-based authorization. While mandates prove that an agent has authority to act, FCB monitors *how* the agent exercises that authority in real-time. + +### Why FCB? + +AP2 mandates validate authority at signing time: + +- ✅ "Agent has a valid IntentMandate with $50,000 budget" +- ✅ "This transaction is within the mandate constraints" + +But mandates don't address runtime behaviors: + +- ❌ "Agent already spent $30,000 today across 10 transactions" +- ❌ "Agent is making purchases 3x faster than normal" +- ❌ "Agent is buying from an unfamiliar vendor in a high-risk region" + +FCB fills this gap by providing **cross-transaction behavioral monitoring** that can trip and require human intervention when something looks wrong. + +## Conceptual Model + +```text +┌─────────────────────────────────────────────────────────────────────────────┐ +│ AGENT GOVERNANCE STACK │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Layer 3: RUNTIME GOVERNANCE (FCB) │ +│ ───────────────────────────────── │ +│ Question: "Should this action proceed RIGHT NOW?" │ +│ Evaluates: Cumulative risk, velocity, anomalies, thresholds │ +│ Output: ALLOW / TRIP (escalate to human) │ +│ │ +│ Layer 2: PAYMENT AUTHORIZATION (AP2 Mandates) │ +│ ───────────────────────────────────────────── │ +│ Question: "Does agent have cryptographic proof of authority?" │ +│ Evaluates: User-signed mandates, intent constraints │ +│ Output: Valid credential / Reject │ +│ │ +│ Layer 1: AGENT IDENTITY & DISCOVERY │ +│ ──────────────────────────────────── │ +│ Question: "Is this agent authentic?" │ +│ Evaluates: Agent cards, trust registries │ +│ Output: Verified identity / Untrusted │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +## FCB States + +The FCB operates as a state machine: + +| State | Behavior | Entry Condition | +| ----- | -------- | --------------- | +| **CLOSED** | Normal operation. Agent acts autonomously. | Initial state; human approves from OPEN; or conditions met from HALF_OPEN | +| **OPEN** | All actions blocked. Requires human review. | Any trip condition fails from CLOSED; or conditions violated from HALF_OPEN | +| **HALF_OPEN** | Limited operations with enhanced monitoring. | Human approves with conditions from OPEN | +| **TERMINATED** | Permanently halted. No recovery. | Human rejects from OPEN; or timeout | + +### State Transitions + +```text +CLOSED ──[trip condition fails]──► OPEN + ▲ │ + │ ┌─────────────┼─────────────┐ + │ │ │ │ + │ [approve] [approve w/conditions] [reject] + │ │ │ │ + │ │ ▼ ▼ + └────────────────────┘ HALF_OPEN TERMINATED + │ + ┌─────────┴─────────┐ + │ │ + [conditions met] [conditions violated] + │ │ + ▼ ▼ + CLOSED OPEN +``` + +## Trip Conditions + +Trip conditions are predicate functions that evaluate agent behavior: + +| Type | Description | Example | +| ---- | ----------- | ------- | +| `VALUE_THRESHOLD` | Single transaction exceeds limit | Order > $100,000 | +| `CUMULATIVE_THRESHOLD` | Running total exceeds threshold | Daily spend > $500,000 | +| `VELOCITY` | Too many actions too quickly | > 10 transactions/minute | +| `AUTHORITY_SCOPE` | Action outside delegated domain | Modifying payment account | +| `ANOMALY` | ML model detects unusual pattern | Behavior inconsistent with baseline | +| `TIME_BASED` | Action during restricted period | Trade outside market hours | +| `DEVIATION` | Significant departure from baseline | Price 30% below historical average | +| `VENDOR_TRUST` | Untrusted counterparty | New vendor in high-risk region | + +## Usage in AP2 Messages + +### Including RiskPayload in Messages + +The `RiskPayload` can be attached to any AP2 message via the `risk_data` DataPart: + +```json +{ + "messageId": "msg_123", + "parts": [ + { + "kind": "data", + "data": { + "ap2.mandates.PaymentMandate": { ... }, + "ap2.risk.RiskPayload": { + "fcb_evaluation": { + "fcb_state": "CLOSED", + "trips_evaluated": 8, + "trips_triggered": 0, + "trip_results": [ + { + "condition_type": "VALUE_THRESHOLD", + "status": "PASS", + "threshold": 100000, + "actual_value": 45000 + }, + { + "condition_type": "CUMULATIVE_THRESHOLD", + "status": "PASS", + "threshold": 500000, + "actual_value": 125000 + } + ], + "risk_score": 0.15, + "evaluated_at": "2026-02-03T14:30:00Z" + }, + "agent_modality": "HUMAN_NOT_PRESENT", + "agent_id": "agent_xyz", + "agent_type": "B2B_BUYER", + "session_id": "session_abc123", + "cumulative_session_value": 125000, + "transaction_count_today": 3 + } + } + } + ] +} +``` + +### FCB Trip with Human Escalation + +When FCB trips, the `human_escalation` field captures the escalation flow: + +```json +{ + "ap2.risk.RiskPayload": { + "fcb_evaluation": { + "fcb_state": "HALF_OPEN", + "previous_state": "OPEN", + "trips_evaluated": 8, + "trips_triggered": 2, + "trip_results": [ + { + "condition_type": "CUMULATIVE_THRESHOLD", + "status": "FAIL", + "threshold": 500000, + "actual_value": 525000, + "message": "Daily cumulative spend exceeds $500,000 limit" + }, + { + "condition_type": "VENDOR_TRUST", + "status": "WARNING", + "message": "New vendor not in approved registry" + } + ], + "risk_score": 0.72, + "human_escalation": { + "escalation_id": "esc_789", + "triggered_at": "2026-02-03T14:30:00Z", + "approver_id": "user_john_smith", + "decision": "APPROVE_WITH_CONDITIONS", + "decided_at": "2026-02-03T14:45:00Z", + "conditions": [ + "Add vendor to approved registry", + "Enhanced monitoring for 7 days" + ], + "notes": "Approved given strong counterparty history" + }, + "evaluated_at": "2026-02-03T14:30:00Z" + }, + "agent_modality": "HUMAN_NOT_PRESENT" + } +} +``` + +## Python Types + +```python +from ap2.types.risk import ( + RiskPayload, + FCBEvaluation, + FCBState, + TripConditionResult, + TripConditionType, + TripConditionStatus, + AgentModality, +) + +# Create an FCB evaluation +evaluation = FCBEvaluation( + fcb_state=FCBState.CLOSED, + trips_evaluated=3, + trips_triggered=0, + trip_results=[ + TripConditionResult( + condition_type=TripConditionType.VALUE_THRESHOLD, + status=TripConditionStatus.PASS, + threshold=100000, + actual_value=45000, + ) + ], + risk_score=0.15, +) + +# Create risk payload +risk_payload = RiskPayload( + fcb_evaluation=evaluation, + agent_modality=AgentModality.HUMAN_NOT_PRESENT, + agent_id="agent_xyz", + session_id="session_abc123", +) +``` + +## Go Types + +```go +import "github.com/google-agentic-commerce/ap2/samples/go/pkg/ap2/types" + +// Create an FCB evaluation +evaluation := types.NewFCBEvaluation(types.FCBStateClosed) +threshold := 100000.0 +actualValue := 45000.0 +riskScore := 0.15 +evaluation.AddTripResult(types.TripConditionResult{ + ConditionType: types.TripConditionValueThreshold, + Status: types.TripConditionStatusPass, + Threshold: &threshold, + ActualValue: &actualValue, +}) +evaluation.RiskScore = &riskScore + +// Create risk payload +riskPayload := types.NewRiskPayload(types.AgentModalityHumanNotPresent) +riskPayload.FCBEvaluation = evaluation +agentID := "agent_xyz" +riskPayload.AgentID = &agentID +``` + +## Benefits for Payment Ecosystem + +### For Merchants + +- Real-time visibility into agent behavior before accepting transaction +- Ability to require higher security for risky transactions + +### For Payment Networks + +- Standardized risk signals for authorization decisions +- Clear audit trail of FCB state and human approvals + +### For Issuers + +- Additional data points for fraud detection +- Visibility into agent vs. human-initiated transactions + +### For Users + +- Confidence that agents operate within guardrails +- Human oversight for exceptional cases + +## References + +- [AP2 Specification Section 7.4: Risk Signals](../specification.md#74-risk-signals) +- [Circuit Breaker Pattern - Michael Nygard, Release It! (2007)](https://pragprog.com/titles/mnee2/release-it-second-edition/) diff --git a/mkdocs.yml b/mkdocs.yml index 72dfc7ec..e0463a9d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -24,6 +24,7 @@ nav: - AP2 and x402: topics/ap2-and-x402.md - Privacy and Security: topics/privacy-and-security.md - Life of a Transaction: topics/life-of-a-transaction.md + - Fiduciary Circuit Breaker: topics/fiduciary-circuit-breaker.md - AP2 specification: specification.md - A2A extension for AP2: a2a-extension.md - Glossary: glossary.md @@ -143,6 +144,7 @@ plugins: - topics/ap2-and-x402.md - topics/privacy-and-security.md - topics/life-of-a-transaction.md + - topics/fiduciary-circuit-breaker.md "Specification": - specification.md - a2a-extension.md diff --git a/samples/go/pkg/ap2/types/risk.go b/samples/go/pkg/ap2/types/risk.go new file mode 100644 index 00000000..6fc9a621 --- /dev/null +++ b/samples/go/pkg/ap2/types/risk.go @@ -0,0 +1,221 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package types provides Fiduciary Circuit Breaker (FCB) risk types for AP2. +// +// This file implements structured types for the Risk Payload referenced in +// AP2 Section 7.4 (Risk Signals). The FCB pattern provides runtime governance +// for autonomous agent transactions through: +// - Trip conditions that evaluate agent behavior against predefined thresholds +// - A state machine for governance (CLOSED → OPEN → HALF_OPEN) +// - Human escalation protocol for exceptional cases +// - Structured risk signals for network/issuer visibility +package types + +import "time" + +// Data key prefix for risk types. +const riskDataKeyPrefix = "ap2.risk." + +// Data keys for AP2 message data parts. +var ( + RiskPayloadDataKey = riskDataKeyPrefix + "RiskPayload" + FCBEvaluationDataKey = riskDataKeyPrefix + "FCBEvaluation" +) + +// TripConditionType represents categories of runtime risk checks. +type TripConditionType string + +const ( + // TripConditionValueThreshold - Single transaction exceeds monetary limit. + TripConditionValueThreshold TripConditionType = "VALUE_THRESHOLD" + + // TripConditionCumulativeThreshold - Running totals exceed threshold. + TripConditionCumulativeThreshold TripConditionType = "CUMULATIVE_THRESHOLD" + + // TripConditionVelocity - Too many actions in a time window. + TripConditionVelocity TripConditionType = "VELOCITY" + + // TripConditionAuthorityScope - Action outside delegated domain. + TripConditionAuthorityScope TripConditionType = "AUTHORITY_SCOPE" + + // TripConditionAnomaly - ML model detects unusual pattern. + TripConditionAnomaly TripConditionType = "ANOMALY" + + // TripConditionTimeBased - Action during restricted time period. + TripConditionTimeBased TripConditionType = "TIME_BASED" + + // TripConditionDeviation - Significant departure from baseline. + TripConditionDeviation TripConditionType = "DEVIATION" + + // TripConditionVendorTrust - Transaction with untrusted counterparty. + TripConditionVendorTrust TripConditionType = "VENDOR_TRUST" + + // TripConditionCustom - Implementation-specific trip condition. + TripConditionCustom TripConditionType = "CUSTOM" +) + +// TripConditionStatus represents the result of evaluating a trip condition. +type TripConditionStatus string + +const ( + // TripConditionStatusPass - Condition satisfied, no risk detected. + TripConditionStatusPass TripConditionStatus = "PASS" + + // TripConditionStatusFail - Condition failed, FCB should trip. + TripConditionStatusFail TripConditionStatus = "FAIL" + + // TripConditionStatusWarning - Approaching threshold. + TripConditionStatusWarning TripConditionStatus = "WARNING" +) + +// FCBState represents states of the Fiduciary Circuit Breaker. +type FCBState string + +const ( + // FCBStateClosed - Normal operation, agent acts autonomously. + FCBStateClosed FCBState = "CLOSED" + + // FCBStateOpen - All actions blocked, requires human review. + FCBStateOpen FCBState = "OPEN" + + // FCBStateHalfOpen - Limited operations with enhanced monitoring. + FCBStateHalfOpen FCBState = "HALF_OPEN" + + // FCBStateTerminated - Permanently halted, no recovery. + FCBStateTerminated FCBState = "TERMINATED" +) + +// AgentModality indicates whether human is present during transaction. +type AgentModality string + +const ( + // AgentModalityHumanPresent - User is in-session. + AgentModalityHumanPresent AgentModality = "HUMAN_PRESENT" + + // AgentModalityHumanNotPresent - User delegated task, not in-session. + AgentModalityHumanNotPresent AgentModality = "HUMAN_NOT_PRESENT" +) + +// EscalationDecision represents human approver's decision. +type EscalationDecision string + +const ( + // EscalationDecisionApprove - Action approved, FCB returns to CLOSED. + EscalationDecisionApprove EscalationDecision = "APPROVE" + + // EscalationDecisionApproveWithConditions - Approved with monitoring. + EscalationDecisionApproveWithConditions EscalationDecision = "APPROVE_WITH_CONDITIONS" + + // EscalationDecisionReject - Action rejected, FCB terminates. + EscalationDecisionReject EscalationDecision = "REJECT" + + // EscalationDecisionEscalateFurther - Forward to higher authority. + EscalationDecisionEscalateFurther EscalationDecision = "ESCALATE_FURTHER" + + // EscalationDecisionModifyAndApprove - Adjust parameters, then approve. + EscalationDecisionModifyAndApprove EscalationDecision = "MODIFY_AND_APPROVE" +) + +// TripConditionResult captures the outcome of one risk check. +type TripConditionResult struct { + ConditionType TripConditionType `json:"condition_type"` // Type of condition evaluated. + Status TripConditionStatus `json:"status"` // Pass, fail, or warning. + Threshold *float64 `json:"threshold,omitempty"` // Limit checked against. + ActualValue *float64 `json:"actual_value,omitempty"` // Observed value. + Message *string `json:"message,omitempty"` // Human-readable explanation. + Suggestion *string `json:"suggestion,omitempty"` // Suggested resolution. +} + +// HumanEscalation captures details when FCB trips and requires human review. +type HumanEscalation struct { + EscalationID string `json:"escalation_id"` // Unique ID for this escalation. + TriggeredAt string `json:"triggered_at,omitempty"` // When triggered (RFC3339). + ApproverID *string `json:"approver_id,omitempty"` // Reviewer who handled this. + Decision *EscalationDecision `json:"decision,omitempty"` // Approver's decision. + DecidedAt *string `json:"decided_at,omitempty"` // When decided (RFC3339). + Conditions []string `json:"conditions,omitempty"` // Conditions for conditional approval. + Notes *string `json:"notes,omitempty"` // Approver notes. + TimeoutAt *string `json:"timeout_at,omitempty"` // Deadline for resolution (RFC3339). + DefaultActionOnTimeout *EscalationDecision `json:"default_action_on_timeout,omitempty"` // Action if timeout expires. +} + +// NewHumanEscalation creates a new HumanEscalation with timestamp. +func NewHumanEscalation(escalationID string) *HumanEscalation { + defaultAction := EscalationDecisionReject + return &HumanEscalation{ + EscalationID: escalationID, + TriggeredAt: time.Now().UTC().Format(time.RFC3339), + DefaultActionOnTimeout: &defaultAction, + } +} + +// FCBEvaluation contains complete FCB evaluation results. +type FCBEvaluation struct { + FCBState FCBState `json:"fcb_state"` // Current FCB state. + PreviousState *FCBState `json:"previous_state,omitempty"` // State before this evaluation. + TripsEvaluated int `json:"trips_evaluated"` // Total conditions checked. + TripsTriggered int `json:"trips_triggered"` // Conditions that triggered. + TripResults []TripConditionResult `json:"trip_results,omitempty"` // Results; use AddTripResult to update. + RiskScore *float64 `json:"risk_score,omitempty"` // Aggregate score 0.0-1.0. + HumanEscalation *HumanEscalation `json:"human_escalation,omitempty"` // Escalation if FCB tripped. + EvaluatedAt string `json:"evaluated_at,omitempty"` // When evaluated (RFC3339). +} + +// NewFCBEvaluation creates a new FCBEvaluation with timestamp. +func NewFCBEvaluation(state FCBState) *FCBEvaluation { + return &FCBEvaluation{ + FCBState: state, + TripResults: []TripConditionResult{}, + EvaluatedAt: time.Now().UTC().Format(time.RFC3339), + } +} + +// AddTripResult adds a trip condition result and updates counters. +func (e *FCBEvaluation) AddTripResult(result TripConditionResult) { + e.TripResults = append(e.TripResults, result) + e.TripsEvaluated++ + if result.Status == TripConditionStatusFail || result.Status == TripConditionStatusWarning { + e.TripsTriggered++ + } +} + +// HasTripped returns true if any trip condition failed. +func (e *FCBEvaluation) HasTripped() bool { + for _, r := range e.TripResults { + if r.Status == TripConditionStatusFail { + return true + } + } + return false +} + +// RiskPayload is the container for risk signals in AP2 messages. +type RiskPayload struct { + FCBEvaluation *FCBEvaluation `json:"fcb_evaluation,omitempty"` // FCB evaluation results. + AgentModality AgentModality `json:"agent_modality"` // Human present or not. + AgentID *string `json:"agent_id,omitempty"` // Agent identifier. + AgentType *string `json:"agent_type,omitempty"` // Agent category. + SessionID *string `json:"session_id,omitempty"` // Session for correlation. + CumulativeSessionValue *float64 `json:"cumulative_session_value,omitempty"` // Total session value so far. + TransactionCountToday *int `json:"transaction_count_today,omitempty"` // Transactions today. + CustomSignals map[string]any `json:"custom_signals,omitempty"` // Implementation-specific signals. +} + +// NewRiskPayload creates a new RiskPayload with default modality. +func NewRiskPayload(modality AgentModality) *RiskPayload { + return &RiskPayload{ + AgentModality: modality, + } +} diff --git a/samples/go/pkg/ap2/types/risk_test.go b/samples/go/pkg/ap2/types/risk_test.go new file mode 100644 index 00000000..2a7c6361 --- /dev/null +++ b/samples/go/pkg/ap2/types/risk_test.go @@ -0,0 +1,368 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package types + +import ( + "encoding/json" + "testing" +) + +func TestTripConditionTypes(t *testing.T) { + expectedTypes := []TripConditionType{ + TripConditionValueThreshold, + TripConditionCumulativeThreshold, + TripConditionVelocity, + TripConditionAuthorityScope, + TripConditionAnomaly, + TripConditionTimeBased, + TripConditionDeviation, + TripConditionVendorTrust, + TripConditionCustom, + } + + for _, ct := range expectedTypes { + if ct == "" { + t.Errorf("TripConditionType should not be empty") + } + } + + // Verify string values match expected + if TripConditionValueThreshold != "VALUE_THRESHOLD" { + t.Errorf("Expected VALUE_THRESHOLD, got %s", TripConditionValueThreshold) + } + if TripConditionCumulativeThreshold != "CUMULATIVE_THRESHOLD" { + t.Errorf("Expected CUMULATIVE_THRESHOLD, got %s", TripConditionCumulativeThreshold) + } +} + +func TestFCBStates(t *testing.T) { + states := []struct { + state FCBState + expected string + }{ + {FCBStateClosed, "CLOSED"}, + {FCBStateOpen, "OPEN"}, + {FCBStateHalfOpen, "HALF_OPEN"}, + {FCBStateTerminated, "TERMINATED"}, + } + + for _, tc := range states { + if string(tc.state) != tc.expected { + t.Errorf("Expected %s, got %s", tc.expected, tc.state) + } + } +} + +func TestNewHumanEscalation(t *testing.T) { + escalation := NewHumanEscalation("esc-12345") + + if escalation.EscalationID != "esc-12345" { + t.Errorf("Expected escalation ID 'esc-12345', got '%s'", escalation.EscalationID) + } + + if escalation.TriggeredAt == "" { + t.Error("Expected triggered_at to be set") + } + + if escalation.DefaultActionOnTimeout == nil { + t.Error("Expected default_action_on_timeout to be set") + } + + if *escalation.DefaultActionOnTimeout != EscalationDecisionReject { + t.Errorf("Expected default action REJECT, got %s", *escalation.DefaultActionOnTimeout) + } +} + +func TestNewFCBEvaluation(t *testing.T) { + eval := NewFCBEvaluation(FCBStateClosed) + + if eval.FCBState != FCBStateClosed { + t.Errorf("Expected state CLOSED, got %s", eval.FCBState) + } + + if eval.TripResults == nil { + t.Error("Expected TripResults to be initialized") + } + + if len(eval.TripResults) != 0 { + t.Errorf("Expected empty TripResults, got %d", len(eval.TripResults)) + } + + if eval.EvaluatedAt == "" { + t.Error("Expected evaluated_at to be set") + } +} + +func TestFCBEvaluationAddTripResult(t *testing.T) { + eval := NewFCBEvaluation(FCBStateClosed) + + // Add a passing result + passResult := TripConditionResult{ + ConditionType: TripConditionValueThreshold, + Status: TripConditionStatusPass, + } + eval.AddTripResult(passResult) + + if eval.TripsEvaluated != 1 { + t.Errorf("Expected 1 trip evaluated, got %d", eval.TripsEvaluated) + } + if eval.TripsTriggered != 0 { + t.Errorf("Expected 0 trips triggered, got %d", eval.TripsTriggered) + } + + // Add a failing result + threshold := 10000.0 + actual := 15000.0 + failResult := TripConditionResult{ + ConditionType: TripConditionValueThreshold, + Status: TripConditionStatusFail, + Threshold: &threshold, + ActualValue: &actual, + } + eval.AddTripResult(failResult) + + if eval.TripsEvaluated != 2 { + t.Errorf("Expected 2 trips evaluated, got %d", eval.TripsEvaluated) + } + if eval.TripsTriggered != 1 { + t.Errorf("Expected 1 trip triggered, got %d", eval.TripsTriggered) + } + + // Add a warning result + warnResult := TripConditionResult{ + ConditionType: TripConditionVelocity, + Status: TripConditionStatusWarning, + } + eval.AddTripResult(warnResult) + + if eval.TripsTriggered != 2 { + t.Errorf("Expected 2 trips triggered (fail + warning), got %d", eval.TripsTriggered) + } +} + +func TestFCBEvaluationHasTripped(t *testing.T) { + eval := NewFCBEvaluation(FCBStateClosed) + + // Initially should not have tripped + if eval.HasTripped() { + t.Error("Expected HasTripped to be false with no results") + } + + // Add passing result + eval.AddTripResult(TripConditionResult{ + ConditionType: TripConditionValueThreshold, + Status: TripConditionStatusPass, + }) + + if eval.HasTripped() { + t.Error("Expected HasTripped to be false with only PASS") + } + + // Add warning result + eval.AddTripResult(TripConditionResult{ + ConditionType: TripConditionVelocity, + Status: TripConditionStatusWarning, + }) + + if eval.HasTripped() { + t.Error("Expected HasTripped to be false with WARNING (not FAIL)") + } + + // Add failing result + eval.AddTripResult(TripConditionResult{ + ConditionType: TripConditionCumulativeThreshold, + Status: TripConditionStatusFail, + }) + + if !eval.HasTripped() { + t.Error("Expected HasTripped to be true after FAIL") + } +} + +func TestNewRiskPayload(t *testing.T) { + payload := NewRiskPayload(AgentModalityHumanPresent) + + if payload.AgentModality != AgentModalityHumanPresent { + t.Errorf("Expected HUMAN_PRESENT, got %s", payload.AgentModality) + } + + payloadNotPresent := NewRiskPayload(AgentModalityHumanNotPresent) + if payloadNotPresent.AgentModality != AgentModalityHumanNotPresent { + t.Errorf("Expected HUMAN_NOT_PRESENT, got %s", payloadNotPresent.AgentModality) + } +} + +func TestRiskPayloadJSONSerialization(t *testing.T) { + agentID := "agent-shopping-001" + sessionID := "sess-abc123" + cumulative := 500.0 + txnCount := 3 + + payload := &RiskPayload{ + FCBEvaluation: NewFCBEvaluation(FCBStateClosed), + AgentModality: AgentModalityHumanNotPresent, + AgentID: &agentID, + SessionID: &sessionID, + CumulativeSessionValue: &cumulative, + TransactionCountToday: &txnCount, + CustomSignals: map[string]any{ + "merchant_trust_score": 0.95, + "buyer_tier": "enterprise", + }, + } + + // Serialize to JSON + jsonBytes, err := json.Marshal(payload) + if err != nil { + t.Fatalf("Failed to marshal RiskPayload: %v", err) + } + + // Deserialize back + var decoded RiskPayload + if err := json.Unmarshal(jsonBytes, &decoded); err != nil { + t.Fatalf("Failed to unmarshal RiskPayload: %v", err) + } + + // Verify fields + if decoded.AgentModality != AgentModalityHumanNotPresent { + t.Errorf("Expected HUMAN_NOT_PRESENT, got %s", decoded.AgentModality) + } + + if decoded.AgentID == nil || *decoded.AgentID != agentID { + t.Errorf("Expected agent ID %s, got %v", agentID, decoded.AgentID) + } + + if decoded.FCBEvaluation == nil { + t.Error("Expected FCBEvaluation to be present") + } + + if decoded.CustomSignals == nil { + t.Error("Expected CustomSignals to be present") + } +} + +func TestTripConditionResultJSONSerialization(t *testing.T) { + threshold := 50000.0 + actual := 75000.0 + message := "Transaction exceeds daily limit" + suggestion := "Request manager approval" + + result := TripConditionResult{ + ConditionType: TripConditionCumulativeThreshold, + Status: TripConditionStatusFail, + Threshold: &threshold, + ActualValue: &actual, + Message: &message, + Suggestion: &suggestion, + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + t.Fatalf("Failed to marshal TripConditionResult: %v", err) + } + + var decoded TripConditionResult + if err := json.Unmarshal(jsonBytes, &decoded); err != nil { + t.Fatalf("Failed to unmarshal TripConditionResult: %v", err) + } + + if decoded.ConditionType != TripConditionCumulativeThreshold { + t.Errorf("Expected CUMULATIVE_THRESHOLD, got %s", decoded.ConditionType) + } + + if decoded.Status != TripConditionStatusFail { + t.Errorf("Expected FAIL, got %s", decoded.Status) + } + + if decoded.Threshold == nil || *decoded.Threshold != threshold { + t.Errorf("Expected threshold %f, got %v", threshold, decoded.Threshold) + } +} + +func TestDataKeysConstant(t *testing.T) { + if RiskPayloadDataKey != "ap2.risk.RiskPayload" { + t.Errorf("Unexpected RiskPayloadDataKey: %s", RiskPayloadDataKey) + } + + if FCBEvaluationDataKey != "ap2.risk.FCBEvaluation" { + t.Errorf("Unexpected FCBEvaluationDataKey: %s", FCBEvaluationDataKey) + } +} + +func TestCompleteEvaluationScenario(t *testing.T) { + // Simulate a complete FCB evaluation scenario + + // 1. Create evaluation in CLOSED state + eval := NewFCBEvaluation(FCBStateClosed) + + // 2. Evaluate VALUE_THRESHOLD - passes + threshold1 := 10000.0 + actual1 := 8500.0 + eval.AddTripResult(TripConditionResult{ + ConditionType: TripConditionValueThreshold, + Status: TripConditionStatusPass, + Threshold: &threshold1, + ActualValue: &actual1, + }) + + // 3. Evaluate CUMULATIVE - fails + threshold2 := 50000.0 + actual2 := 65000.0 + msg := "Daily cumulative limit exceeded" + eval.AddTripResult(TripConditionResult{ + ConditionType: TripConditionCumulativeThreshold, + Status: TripConditionStatusFail, + Threshold: &threshold2, + ActualValue: &actual2, + Message: &msg, + }) + + // 4. Evaluation should have tripped + if !eval.HasTripped() { + t.Error("Expected evaluation to have tripped") + } + + // 5. Transition state to OPEN + eval.PreviousState = &eval.FCBState + eval.FCBState = FCBStateOpen + + // 6. Create escalation + eval.HumanEscalation = NewHumanEscalation("esc-001") + + // 7. Wrap in RiskPayload + agentID := "shopping-agent-prod" + payload := &RiskPayload{ + FCBEvaluation: eval, + AgentModality: AgentModalityHumanNotPresent, + AgentID: &agentID, + } + + // 8. Verify full scenario + if payload.FCBEvaluation.FCBState != FCBStateOpen { + t.Errorf("Expected OPEN state, got %s", payload.FCBEvaluation.FCBState) + } + + if payload.FCBEvaluation.TripsEvaluated != 2 { + t.Errorf("Expected 2 evaluations, got %d", payload.FCBEvaluation.TripsEvaluated) + } + + if payload.FCBEvaluation.TripsTriggered != 1 { + t.Errorf("Expected 1 triggered, got %d", payload.FCBEvaluation.TripsTriggered) + } + + if payload.FCBEvaluation.HumanEscalation == nil { + t.Error("Expected HumanEscalation to be set") + } +} diff --git a/src/ap2/types/__init__.py b/src/ap2/types/__init__.py index 9240c2aa..68a2520d 100644 --- a/src/ap2/types/__init__.py +++ b/src/ap2/types/__init__.py @@ -11,3 +11,54 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. + +"""AP2 type definitions.""" + +from ap2.types.mandate import ( + CartContents, + CartMandate, + IntentMandate, + PaymentMandate, + PaymentMandateContents, + CART_MANDATE_DATA_KEY, + INTENT_MANDATE_DATA_KEY, + PAYMENT_MANDATE_DATA_KEY, +) + +from ap2.types.risk import ( + AgentModality, + EscalationDecision, + FCBEvaluation, + FCBState, + HumanEscalation, + RiskPayload, + TripConditionResult, + TripConditionStatus, + TripConditionType, + FCB_EVALUATION_DATA_KEY, + RISK_PAYLOAD_DATA_KEY, +) + +__all__ = [ + # Mandate types + "CartContents", + "CartMandate", + "IntentMandate", + "PaymentMandate", + "PaymentMandateContents", + "CART_MANDATE_DATA_KEY", + "INTENT_MANDATE_DATA_KEY", + "PAYMENT_MANDATE_DATA_KEY", + # Risk types (FCB extension) + "AgentModality", + "EscalationDecision", + "FCBEvaluation", + "FCBState", + "HumanEscalation", + "RiskPayload", + "TripConditionResult", + "TripConditionStatus", + "TripConditionType", + "FCB_EVALUATION_DATA_KEY", + "RISK_PAYLOAD_DATA_KEY", +] diff --git a/src/ap2/types/risk.py b/src/ap2/types/risk.py new file mode 100644 index 00000000..9a967f62 --- /dev/null +++ b/src/ap2/types/risk.py @@ -0,0 +1,308 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Fiduciary Circuit Breaker (FCB) risk types for the Agent Payments Protocol. + +This module provides structured types for the Risk Payload referenced in +AP2 Section 7.4 (Risk Signals). It implements the Fiduciary Circuit Breaker +pattern for runtime governance of autonomous agent transactions. + +The FCB pattern provides: +- Trip conditions that evaluate agent behavior against predefined thresholds +- A state machine for governance (CLOSED → OPEN → HALF_OPEN) +- Human escalation protocol for exceptional cases +- Structured risk signals for network/issuer visibility +""" + +from datetime import datetime +from datetime import timezone +from enum import Enum +from typing import Any +from typing import Optional + +from pydantic import BaseModel +from pydantic import Field + + +# Data key prefix for risk types +_RISK_DATA_KEY_PREFIX = "ap2.risk." + +# Data keys for AP2 message data parts (composed to avoid false positive secret detection) +RISK_PAYLOAD_DATA_KEY = f"{_RISK_DATA_KEY_PREFIX}RiskPayload" +FCB_EVALUATION_DATA_KEY = f"{_RISK_DATA_KEY_PREFIX}FCBEvaluation" + + +class TripConditionType(str, Enum): + """Categories of runtime risk checks that can trigger FCB. + + These trip conditions evaluate agent behavior beyond what mandates capture, + focusing on cumulative, temporal, and anomalous patterns. + """ + + VALUE_THRESHOLD = "VALUE_THRESHOLD" + """Single transaction exceeds monetary limit.""" + + CUMULATIVE_THRESHOLD = "CUMULATIVE_THRESHOLD" + """Running totals across transactions exceed threshold.""" + + VELOCITY = "VELOCITY" + """Too many actions in a time window (e.g., >10 txns/minute).""" + + AUTHORITY_SCOPE = "AUTHORITY_SCOPE" + """Action outside the agent's delegated domain.""" + + ANOMALY = "ANOMALY" + """ML model detects unusual behavioral pattern.""" + + TIME_BASED = "TIME_BASED" + """Action during restricted time period.""" + + DEVIATION = "DEVIATION" + """Significant departure from historical baseline.""" + + VENDOR_TRUST = "VENDOR_TRUST" + """Transaction with unverified or untrusted counterparty.""" + + CUSTOM = "CUSTOM" + """Implementation-specific trip condition.""" + + +class TripConditionStatus(str, Enum): + """Result of evaluating a single trip condition.""" + + PASS = "PASS" + """Condition satisfied, no risk detected.""" + + FAIL = "FAIL" + """Condition failed, FCB should trip.""" + + WARNING = "WARNING" + """Approaching threshold, enhanced monitoring recommended.""" + + +class FCBState(str, Enum): + """States of the Fiduciary Circuit Breaker. + + The FCB operates as a state machine that governs agent autonomy: + - CLOSED: Normal operation, agent acts autonomously + - OPEN: All consequential actions blocked, requires human review + - HALF_OPEN: Limited operations with enhanced monitoring + - TERMINATED: Permanently halted, no recovery + """ + + CLOSED = "CLOSED" + """Normal operation. Agent has full delegated authority.""" + + OPEN = "OPEN" + """All consequential actions blocked. Requires human review.""" + + HALF_OPEN = "HALF_OPEN" + """Limited operations permitted. Enhanced monitoring active.""" + + TERMINATED = "TERMINATED" + """Permanently halted. No recovery possible.""" + + +class AgentModality(str, Enum): + """Transaction modality indicating human presence.""" + + HUMAN_PRESENT = "HUMAN_PRESENT" + """User is in-session during the transaction.""" + + HUMAN_NOT_PRESENT = "HUMAN_NOT_PRESENT" + """User delegated task to agent, not in-session.""" + + +class EscalationDecision(str, Enum): + """Human approver's decision on an escalated action.""" + + APPROVE = "APPROVE" + """Action approved, FCB returns to CLOSED.""" + + APPROVE_WITH_CONDITIONS = "APPROVE_WITH_CONDITIONS" + """Action approved with monitoring, FCB moves to HALF_OPEN.""" + + REJECT = "REJECT" + """Action rejected, FCB moves to TERMINATED.""" + + ESCALATE_FURTHER = "ESCALATE_FURTHER" + """Forward to higher authority.""" + + MODIFY_AND_APPROVE = "MODIFY_AND_APPROVE" + """Adjust action parameters, then approve.""" + + +class TripConditionResult(BaseModel): + """Result of evaluating a single trip condition. + + Captures the outcome of one risk check, including the threshold, + actual value, and any diagnostic message. + """ + + condition_type: TripConditionType = Field( + ..., + description="The type of trip condition that was evaluated.", + ) + status: TripConditionStatus = Field( + ..., + description="Whether the condition passed, failed, or warned.", + ) + threshold: Optional[float] = Field( + None, + description="The threshold value that was checked against.", + ) + actual_value: Optional[float] = Field( + None, + description="The actual value observed.", + ) + message: Optional[str] = Field( + None, + description="Human-readable explanation of the result.", + ) + suggestion: Optional[str] = Field( + None, + description="Suggested action or resolution.", + ) + + +class HumanEscalation(BaseModel): + """Details of a human escalation when FCB trips. + + Captures the escalation request, the human's decision, and any + conditions attached to the approval. + """ + + escalation_id: str = Field( + ..., + description="Unique identifier for this escalation.", + ) + triggered_at: str = Field( + description="When the escalation was triggered, ISO 8601 format.", + default_factory=lambda: datetime.now(timezone.utc).isoformat(), + ) + approver_id: Optional[str] = Field( + None, + description="Identifier of the human who reviewed the escalation.", + ) + decision: Optional[EscalationDecision] = Field( + None, + description="The human approver's decision.", + ) + decided_at: Optional[str] = Field( + None, + description="When the decision was made, ISO 8601 format.", + ) + conditions: Optional[list[str]] = Field( + None, + description="Conditions attached to an APPROVE_WITH_CONDITIONS decision.", + ) + notes: Optional[str] = Field( + None, + description="Free-form notes from the approver.", + ) + timeout_at: Optional[str] = Field( + None, + description="When the escalation will timeout if not resolved.", + ) + default_action_on_timeout: Optional[EscalationDecision] = Field( + EscalationDecision.REJECT, + description="Action to take if escalation times out.", + ) + + +class FCBEvaluation(BaseModel): + """Complete FCB evaluation result for a transaction. + + This is the primary object attached to the RiskPayload, containing + the full evaluation results from the Fiduciary Circuit Breaker. + """ + + fcb_state: FCBState = Field( + ..., + description="Current state of the FCB after evaluation.", + ) + previous_state: Optional[FCBState] = Field( + None, + description="FCB state before this evaluation (for state transitions).", + ) + trips_evaluated: int = Field( + ..., + description="Total number of trip conditions evaluated.", + ) + trips_triggered: int = Field( + ..., + description="Number of trip conditions that triggered (FAIL or WARNING).", + ) + trip_results: list[TripConditionResult] = Field( + default_factory=list, + description="Individual results for each trip condition evaluated.", + ) + risk_score: Optional[float] = Field( + None, + ge=0.0, + le=1.0, + description="Aggregate risk score from 0.0 (lowest) to 1.0 (highest).", + ) + human_escalation: Optional[HumanEscalation] = Field( + None, + description="Escalation details if FCB tripped and required human review.", + ) + evaluated_at: str = Field( + description="When the FCB evaluation occurred, ISO 8601 format.", + default_factory=lambda: datetime.now(timezone.utc).isoformat(), + ) + + +class RiskPayload(BaseModel): + """Container for risk-related signals in AP2 messages. + + This is the top-level risk object that can be attached to IntentMandate, + CartMandate, and PaymentMandate messages via the `risk_data` DataPart. + + It provides visibility to merchants, payment processors, networks, and + issuers about the runtime governance state of the agent transaction. + """ + + fcb_evaluation: Optional[FCBEvaluation] = Field( + None, + description="Fiduciary Circuit Breaker evaluation results.", + ) + agent_modality: AgentModality = Field( + AgentModality.HUMAN_PRESENT, + description="Whether user was present during transaction.", + ) + agent_id: Optional[str] = Field( + None, + description="Identifier of the agent initiating the transaction.", + ) + agent_type: Optional[str] = Field( + None, + description="Type/category of agent (e.g., 'SHOPPING', 'B2B_BUYER').", + ) + session_id: Optional[str] = Field( + None, + description="Session identifier for correlation.", + ) + cumulative_session_value: Optional[float] = Field( + None, + description="Total transaction value in this session so far.", + ) + transaction_count_today: Optional[int] = Field( + None, + description="Number of transactions by this agent today.", + ) + custom_signals: Optional[dict[str, Any]] = Field( + None, + description="Implementation-specific risk signals.", + ) diff --git a/tests/test_risk.py b/tests/test_risk.py new file mode 100644 index 00000000..024ecf11 --- /dev/null +++ b/tests/test_risk.py @@ -0,0 +1,462 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for Fiduciary Circuit Breaker (FCB) risk types.""" + +import json + +import pytest + +from ap2.types.risk import ( + AgentModality, + EscalationDecision, + FCB_EVALUATION_DATA_KEY, + FCBEvaluation, + FCBState, + HumanEscalation, + RISK_PAYLOAD_DATA_KEY, + RiskPayload, + TripConditionResult, + TripConditionStatus, + TripConditionType, +) + + +class TestTripConditionType: + """Tests for TripConditionType enum.""" + + def test_all_types_exist(self): + """Verify all expected trip condition types are defined.""" + expected = [ + "VALUE_THRESHOLD", + "CUMULATIVE_THRESHOLD", + "VELOCITY", + "AUTHORITY_SCOPE", + "ANOMALY", + "TIME_BASED", + "DEVIATION", + "VENDOR_TRUST", + "CUSTOM", + ] + actual = [e.value for e in TripConditionType] + assert set(expected) == set(actual) + + def test_string_values(self): + """Verify enum values match string representation.""" + assert TripConditionType.VALUE_THRESHOLD == "VALUE_THRESHOLD" + assert TripConditionType.CUMULATIVE_THRESHOLD == "CUMULATIVE_THRESHOLD" + assert TripConditionType.VELOCITY == "VELOCITY" + + +class TestTripConditionStatus: + """Tests for TripConditionStatus enum.""" + + def test_all_statuses_exist(self): + """Verify all expected statuses are defined.""" + assert TripConditionStatus.PASS == "PASS" + assert TripConditionStatus.FAIL == "FAIL" + assert TripConditionStatus.WARNING == "WARNING" + + +class TestFCBState: + """Tests for FCBState enum.""" + + def test_all_states_exist(self): + """Verify all FCB states are defined.""" + assert FCBState.CLOSED == "CLOSED" + assert FCBState.OPEN == "OPEN" + assert FCBState.HALF_OPEN == "HALF_OPEN" + assert FCBState.TERMINATED == "TERMINATED" + + +class TestAgentModality: + """Tests for AgentModality enum.""" + + def test_modalities(self): + """Verify both modalities exist.""" + assert AgentModality.HUMAN_PRESENT == "HUMAN_PRESENT" + assert AgentModality.HUMAN_NOT_PRESENT == "HUMAN_NOT_PRESENT" + + +class TestEscalationDecision: + """Tests for EscalationDecision enum.""" + + def test_all_decisions_exist(self): + """Verify all escalation decisions are defined.""" + expected = [ + "APPROVE", + "APPROVE_WITH_CONDITIONS", + "REJECT", + "ESCALATE_FURTHER", + "MODIFY_AND_APPROVE", + ] + actual = [e.value for e in EscalationDecision] + assert set(expected) == set(actual) + + +class TestTripConditionResult: + """Tests for TripConditionResult model.""" + + def test_minimal_result(self): + """Test creating result with only required fields.""" + result = TripConditionResult( + condition_type=TripConditionType.VALUE_THRESHOLD, + status=TripConditionStatus.PASS, + ) + assert result.condition_type == TripConditionType.VALUE_THRESHOLD + assert result.status == TripConditionStatus.PASS + assert result.threshold is None + assert result.actual_value is None + + def test_full_result(self): + """Test creating result with all fields.""" + result = TripConditionResult( + condition_type=TripConditionType.CUMULATIVE_THRESHOLD, + status=TripConditionStatus.FAIL, + threshold=50000.0, + actual_value=75000.0, + message="Daily limit exceeded", + suggestion="Request manager approval", + ) + assert result.threshold == 50000.0 + assert result.actual_value == 75000.0 + assert result.message == "Daily limit exceeded" + + def test_json_serialization(self): + """Test JSON round-trip.""" + result = TripConditionResult( + condition_type=TripConditionType.VELOCITY, + status=TripConditionStatus.WARNING, + threshold=10.0, + actual_value=8.0, + ) + json_str = result.model_dump_json() + decoded = TripConditionResult.model_validate_json(json_str) + assert decoded.condition_type == result.condition_type + assert decoded.status == result.status + + +class TestHumanEscalation: + """Tests for HumanEscalation model.""" + + def test_creation_with_defaults(self): + """Test creating escalation with default values.""" + escalation = HumanEscalation(escalation_id="esc-12345") + assert escalation.escalation_id == "esc-12345" + assert escalation.triggered_at is not None + assert escalation.default_action_on_timeout == EscalationDecision.REJECT + + def test_full_escalation(self): + """Test creating escalation with all fields.""" + escalation = HumanEscalation( + escalation_id="esc-001", + triggered_at="2025-02-03T10:00:00Z", + approver_id="manager-123", + decision=EscalationDecision.APPROVE_WITH_CONDITIONS, + decided_at="2025-02-03T10:15:00Z", + conditions=["Enhanced monitoring for 24h", "Max single txn $5000"], + notes="Approved after vendor verification", + ) + assert escalation.decision == EscalationDecision.APPROVE_WITH_CONDITIONS + assert len(escalation.conditions) == 2 + + +class TestFCBEvaluation: + """Tests for FCBEvaluation model.""" + + def test_minimal_evaluation(self): + """Test creating evaluation with required fields.""" + eval = FCBEvaluation( + fcb_state=FCBState.CLOSED, + trips_evaluated=0, + trips_triggered=0, + ) + assert eval.fcb_state == FCBState.CLOSED + assert eval.trip_results == [] + + def test_evaluation_with_results(self): + """Test evaluation with trip results.""" + results = [ + TripConditionResult( + condition_type=TripConditionType.VALUE_THRESHOLD, + status=TripConditionStatus.PASS, + ), + TripConditionResult( + condition_type=TripConditionType.CUMULATIVE_THRESHOLD, + status=TripConditionStatus.FAIL, + ), + ] + eval = FCBEvaluation( + fcb_state=FCBState.OPEN, + trips_evaluated=2, + trips_triggered=1, + trip_results=results, + risk_score=0.75, + ) + assert len(eval.trip_results) == 2 + assert eval.risk_score == 0.75 + + def test_risk_score_bounds(self): + """Test risk_score validation.""" + from pydantic import ValidationError + + # Valid score + eval = FCBEvaluation( + fcb_state=FCBState.CLOSED, + trips_evaluated=1, + trips_triggered=0, + risk_score=0.5, + ) + assert eval.risk_score == 0.5 + + # Score at boundaries + eval_min = FCBEvaluation( + fcb_state=FCBState.CLOSED, + trips_evaluated=1, + trips_triggered=0, + risk_score=0.0, + ) + assert eval_min.risk_score == 0.0 + + eval_max = FCBEvaluation( + fcb_state=FCBState.CLOSED, + trips_evaluated=1, + trips_triggered=0, + risk_score=1.0, + ) + assert eval_max.risk_score == 1.0 + + # Invalid scores - below minimum + with pytest.raises(ValidationError): + FCBEvaluation( + fcb_state=FCBState.CLOSED, + trips_evaluated=1, + trips_triggered=0, + risk_score=-0.1, + ) + + # Invalid scores - above maximum + with pytest.raises(ValidationError): + FCBEvaluation( + fcb_state=FCBState.CLOSED, + trips_evaluated=1, + trips_triggered=0, + risk_score=1.1, + ) + + def test_evaluation_with_escalation(self): + """Test evaluation with human escalation.""" + escalation = HumanEscalation( + escalation_id="esc-001", + approver_id="manager-123", + decision=EscalationDecision.APPROVE, + ) + eval = FCBEvaluation( + fcb_state=FCBState.HALF_OPEN, + previous_state=FCBState.OPEN, + trips_evaluated=3, + trips_triggered=1, + human_escalation=escalation, + ) + assert eval.previous_state == FCBState.OPEN + assert eval.human_escalation is not None + + +class TestRiskPayload: + """Tests for RiskPayload model.""" + + def test_minimal_payload(self): + """Test creating payload with defaults.""" + payload = RiskPayload() + assert payload.agent_modality == AgentModality.HUMAN_PRESENT + assert payload.fcb_evaluation is None + + def test_full_payload(self): + """Test creating payload with all fields.""" + eval = FCBEvaluation( + fcb_state=FCBState.CLOSED, + trips_evaluated=2, + trips_triggered=0, + ) + payload = RiskPayload( + fcb_evaluation=eval, + agent_modality=AgentModality.HUMAN_NOT_PRESENT, + agent_id="shopping-agent-001", + agent_type="SHOPPING", + session_id="sess-abc123", + cumulative_session_value=1500.0, + transaction_count_today=5, + custom_signals={ + "merchant_trust_score": 0.95, + "buyer_tier": "enterprise", + }, + ) + assert payload.agent_id == "shopping-agent-001" + assert payload.custom_signals["buyer_tier"] == "enterprise" + + def test_json_serialization(self): + """Test JSON round-trip for complex payload.""" + results = [ + TripConditionResult( + condition_type=TripConditionType.VALUE_THRESHOLD, + status=TripConditionStatus.PASS, + threshold=10000.0, + actual_value=8500.0, + ), + ] + eval = FCBEvaluation( + fcb_state=FCBState.CLOSED, + trips_evaluated=1, + trips_triggered=0, + trip_results=results, + ) + payload = RiskPayload( + fcb_evaluation=eval, + agent_modality=AgentModality.HUMAN_NOT_PRESENT, + agent_id="test-agent", + ) + + # Serialize + json_str = payload.model_dump_json() + + # Deserialize + decoded = RiskPayload.model_validate_json(json_str) + + assert decoded.agent_id == "test-agent" + assert decoded.fcb_evaluation is not None + assert len(decoded.fcb_evaluation.trip_results) == 1 + + def test_custom_signals_any_type(self): + """Test custom_signals accepts various types.""" + payload = RiskPayload( + custom_signals={ + "string_val": "hello", + "int_val": 42, + "float_val": 3.14, + "bool_val": True, + "list_val": [1, 2, 3], + "nested": {"key": "value"}, + } + ) + assert payload.custom_signals["int_val"] == 42 + assert payload.custom_signals["nested"]["key"] == "value" + + +class TestDataKeys: + """Tests for data key constants.""" + + def test_risk_payload_key(self): + """Verify RISK_PAYLOAD_DATA_KEY constant.""" + # Compose expected value at runtime to avoid false positive secret detection + expected = "ap2.risk." + "RiskPayload" + assert RISK_PAYLOAD_DATA_KEY == expected + + def test_fcb_evaluation_key(self): + """Verify FCB_EVALUATION_DATA_KEY constant.""" + # Compose expected value at runtime to avoid false positive secret detection + expected = "ap2.risk." + "FCBEvaluation" + assert FCB_EVALUATION_DATA_KEY == expected + + +class TestCompleteScenario: + """Integration tests for complete FCB evaluation scenarios.""" + + def test_b2b_quote_scenario(self): + """Test complete B2B quote negotiation scenario. + + Scenario: Agent negotiating $85,000 order, triggers cumulative + threshold, escalates to human, gets approved with conditions. + """ + # Step 1: Evaluate trip conditions + results = [ + TripConditionResult( + condition_type=TripConditionType.VALUE_THRESHOLD, + status=TripConditionStatus.PASS, + threshold=100000.0, + actual_value=85000.0, + message="Single transaction within limit", + ), + TripConditionResult( + condition_type=TripConditionType.CUMULATIVE_THRESHOLD, + status=TripConditionStatus.FAIL, + threshold=200000.0, + actual_value=235000.0, + message="Daily cumulative exceeds $200k limit", + suggestion="Escalate to procurement manager", + ), + TripConditionResult( + condition_type=TripConditionType.VENDOR_TRUST, + status=TripConditionStatus.PASS, + message="Vendor in approved list", + ), + ] + + # Step 2: FCB trips and opens + eval = FCBEvaluation( + fcb_state=FCBState.OPEN, + previous_state=FCBState.CLOSED, + trips_evaluated=3, + trips_triggered=1, + trip_results=results, + risk_score=0.65, + ) + + # Step 3: Create escalation + escalation = HumanEscalation( + escalation_id="esc-b2b-001", + triggered_at="2025-02-03T14:30:00Z", + timeout_at="2025-02-03T15:30:00Z", + ) + eval.human_escalation = escalation + + # Step 4: Human approves with conditions + eval.human_escalation.approver_id = "procurement-mgr-456" + eval.human_escalation.decision = EscalationDecision.APPROVE_WITH_CONDITIONS + eval.human_escalation.decided_at = "2025-02-03T14:45:00Z" + eval.human_escalation.conditions = [ + "Enhanced monitoring for 48 hours", + "Require delivery confirmation before payment release", + ] + eval.human_escalation.notes = "Approved - vendor verified, Q4 budget allows" + + # Step 5: FCB moves to HALF_OPEN + eval.previous_state = eval.fcb_state + eval.fcb_state = FCBState.HALF_OPEN + + # Step 6: Wrap in RiskPayload + payload = RiskPayload( + fcb_evaluation=eval, + agent_modality=AgentModality.HUMAN_NOT_PRESENT, + agent_id="b2b-buyer-agent-001", + agent_type="B2B_BUYER", + session_id="negotiation-sess-789", + cumulative_session_value=235000.0, + transaction_count_today=4, + custom_signals={ + "vendor_id": "vendor-acme-123", + "contract_id": "contract-2025-001", + "negotiation_rounds": 3, + }, + ) + + # Verify final state + assert payload.fcb_evaluation.fcb_state == FCBState.HALF_OPEN + assert payload.fcb_evaluation.human_escalation.decision == EscalationDecision.APPROVE_WITH_CONDITIONS + assert len(payload.fcb_evaluation.human_escalation.conditions) == 2 + assert payload.agent_type == "B2B_BUYER" + + # Verify JSON serialization of complete scenario + json_output = json.loads(payload.model_dump_json()) + assert json_output["fcb_evaluation"]["fcb_state"] == "HALF_OPEN" + assert json_output["custom_signals"]["negotiation_rounds"] == 3