consequence-gate 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,290 @@
1
+ """
2
+ OutboundCommunicationSimulator: outcome simulator for email/SMS/notification blasts.
3
+
4
+ Models:
5
+ - Recipient blast radius (total recipients, segment breakdown)
6
+ - Unsubscribe suppression check (cross-references recent unsubscribes)
7
+ - Canary cohort analysis (sends to small cohort first, measures bounce/complaint rate)
8
+ - Reputation risk scoring (sender domain reputation impact based on volume + content type)
9
+ - Irreversibility: once sent, email/SMS cannot be recalled (1.0); in-app notifications can be retracted (0.3)
10
+
11
+ Unlike financial/database simulators, this domain has no "partial execution" state —
12
+ a send is atomic and irreversible. The gate's job is to prevent oversends before they
13
+ happen, not to model intermediate states.
14
+ """
15
+
16
+ from dataclasses import dataclass, field
17
+ from typing import Any, Dict, List, Optional
18
+ from enum import Enum
19
+
20
+ from ..core.models import GateDecision, EvaluationResult
21
+ from ..core.circuit_breaker import SteerCircuitBreaker
22
+
23
+
24
+ class CommunicationChannel(str, Enum):
25
+ EMAIL = "email"
26
+ SMS = "sms"
27
+ PUSH = "push_notification"
28
+ IN_APP = "in_app_notification"
29
+
30
+
31
+ @dataclass
32
+ class CommunicationBlastDelta:
33
+ tool_name: str
34
+ channel: str
35
+ total_recipients: int
36
+ segment_breakdown: Dict[str, int]
37
+ has_unsubscribe_suppression: bool
38
+ canary_cohort_size: int
39
+ predicted_bounce_rate: float
40
+ predicted_complaint_rate: float
41
+ sender_reputation_impact: float
42
+ irreversibility_score: float
43
+ confidence: float
44
+ natural_key: str
45
+ simulated_side_effects: List[str] = field(default_factory=list)
46
+
47
+
48
+ class OutboundCommunicationSimulator:
49
+ """
50
+ Simulates outbound communication blast radius and reputation risk.
51
+
52
+ Key design decisions:
53
+ - No partial execution modeling — sends are atomic and irreversible.
54
+ - Unsubscribe suppression is mandatory for compliance (CAN-SPAM, GDPR).
55
+ - Canary cohorts are optional but recommended for high-volume sends.
56
+ - Reputation impact is modeled as a function of volume + predicted complaint rate.
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ max_autonomous_recipients: int = 10000,
62
+ max_daily_send_limit: int = 100000,
63
+ canary_min_size: int = 100,
64
+ canary_max_bounce_rate: float = 0.05,
65
+ canary_max_complaint_rate: float = 0.01,
66
+ ):
67
+ self.max_autonomous_recipients = max_autonomous_recipients
68
+ self.max_daily_send_limit = max_daily_send_limit
69
+ self.canary_min_size = canary_min_size
70
+ self.canary_max_bounce_rate = canary_max_bounce_rate
71
+ self.canary_max_complaint_rate = canary_max_complaint_rate
72
+
73
+ def simulate(
74
+ self,
75
+ tool_name: str,
76
+ args: Dict[str, Any],
77
+ context: Dict[str, Any],
78
+ ) -> CommunicationBlastDelta:
79
+ channel = args.get("channel", "email").lower()
80
+ recipient_list = args.get("recipients", [])
81
+ segment_filter = args.get("segment_filter")
82
+ suppress_unsubscribes = args.get("suppress_unsubscribes", True)
83
+ canary_enabled = args.get("canary_enabled", False)
84
+ canary_size = args.get("canary_size", self.canary_min_size)
85
+
86
+ natural_key = f"{channel}:{args.get('campaign_id') or len(recipient_list)}"
87
+
88
+ # Calculate total recipients
89
+ if recipient_list:
90
+ total_recipients = len(recipient_list)
91
+ elif segment_filter and "segment_counts" in context:
92
+ total_recipients = context["segment_counts"].get(segment_filter, 0)
93
+ else:
94
+ total_recipients = 0
95
+
96
+ # Segment breakdown
97
+ segment_breakdown = {}
98
+ if "segment_counts" in context and segment_filter:
99
+ segment_breakdown = {segment_filter: total_recipients}
100
+ elif "segment_counts" in context:
101
+ segment_breakdown = context["segment_counts"]
102
+ else:
103
+ segment_breakdown = {"unknown": total_recipients}
104
+
105
+ # Unsubscribe suppression check
106
+ has_suppression = bool(suppress_unsubscribes)
107
+ recipients_without_suppression = total_recipients if not has_suppression else 0
108
+
109
+ # Canary cohort analysis
110
+ if canary_enabled and total_recipients > self.canary_min_size:
111
+ historical_bounce_rate = context.get("historical_bounce_rate", 0.02)
112
+ historical_complaint_rate = context.get("historical_complaint_rate", 0.005)
113
+ predicted_bounce = historical_bounce_rate
114
+ predicted_complaint = historical_complaint_rate
115
+ else:
116
+ predicted_bounce = 0.0
117
+ predicted_complaint = 0.0
118
+
119
+ # Sender reputation impact
120
+ reputation_impact = -(total_recipients * predicted_complaint) / 5000.0
121
+ if channel == "sms":
122
+ reputation_impact *= 1.5
123
+
124
+ # Irreversibility scoring
125
+ if channel in ("email", "sms"):
126
+ irreversibility = 1.0
127
+ elif channel == "push_notification":
128
+ irreversibility = 0.8
129
+ else:
130
+ irreversibility = 0.3
131
+
132
+ # Confidence scoring
133
+ has_segment_data = "segment_counts" in context
134
+ has_historical_data = "historical_bounce_rate" in context
135
+ confidence = 0.90 if (has_segment_data and has_historical_data) else 0.50
136
+
137
+ side_effects = []
138
+ if total_recipients > self.max_autonomous_recipients:
139
+ side_effects.append(
140
+ f"Recipient count ({total_recipients}) exceeds autonomous threshold "
141
+ f"({self.max_autonomous_recipients})"
142
+ )
143
+ if not suppress_unsubscribes and total_recipients > 0:
144
+ side_effects.append(
145
+ f"COMPLIANCE RISK: {recipients_without_suppression} recipients without unsubscribe suppression"
146
+ )
147
+ if canary_enabled and predicted_bounce > self.canary_max_bounce_rate:
148
+ side_effects.append(
149
+ f"Canary predicts bounce rate {predicted_bounce:.1%} exceeds threshold {self.canary_max_bounce_rate:.1%}"
150
+ )
151
+ if canary_enabled and predicted_complaint > self.canary_max_complaint_rate:
152
+ side_effects.append(
153
+ f"Canary predicts complaint rate {predicted_complaint:.2%} exceeds threshold {self.canary_max_complaint_rate:.1%}"
154
+ )
155
+ if reputation_impact < -0.5:
156
+ side_effects.append(
157
+ f"Severe sender reputation risk: {reputation_impact:.2f} impact score"
158
+ )
159
+
160
+ return CommunicationBlastDelta(
161
+ tool_name=tool_name,
162
+ channel=channel,
163
+ total_recipients=total_recipients,
164
+ segment_breakdown=segment_breakdown,
165
+ has_unsubscribe_suppression=has_suppression,
166
+ canary_cohort_size=canary_size if canary_enabled else 0,
167
+ predicted_bounce_rate=predicted_bounce,
168
+ predicted_complaint_rate=predicted_complaint,
169
+ sender_reputation_impact=reputation_impact,
170
+ irreversibility_score=irreversibility,
171
+ confidence=confidence,
172
+ natural_key=natural_key,
173
+ simulated_side_effects=side_effects,
174
+ )
175
+
176
+ def evaluate(
177
+ self,
178
+ delta: CommunicationBlastDelta,
179
+ circuit_breaker: SteerCircuitBreaker,
180
+ ) -> EvaluationResult:
181
+ if delta.confidence < 0.60:
182
+ return EvaluationResult(
183
+ decision=GateDecision.ASK,
184
+ confidence=delta.confidence,
185
+ reason="Insufficient context (missing segment or historical data) to evaluate send safely.",
186
+ )
187
+
188
+ if not delta.has_unsubscribe_suppression and delta.total_recipients > 0:
189
+ return EvaluationResult(
190
+ decision=GateDecision.DENY,
191
+ confidence=delta.confidence,
192
+ reason=f"COMPLIANCE VIOLATION: Cannot send to {delta.total_recipients} recipients without unsubscribe suppression (CAN-SPAM/GDPR).",
193
+ )
194
+
195
+ if delta.sender_reputation_impact < -0.5:
196
+ return EvaluationResult(
197
+ decision=GateDecision.DENY,
198
+ confidence=delta.confidence,
199
+ reason=f"Critical sender reputation risk: {delta.sender_reputation_impact:.2f} impact score would severely damage deliverability.",
200
+ )
201
+
202
+ if delta.total_recipients > self.max_autonomous_recipients:
203
+ canary_size = min(self.canary_min_size, delta.total_recipients // 10)
204
+ base_steer = {
205
+ "guidance": (
206
+ f"Direct send to {delta.total_recipients} recipients exceeds autonomous threshold "
207
+ f"({self.max_autonomous_recipients}). Option A: Run canary cohort of {canary_size} recipients first. "
208
+ f"Option B: Stage send in batches of {self.max_autonomous_recipients} with manual approval between batches."
209
+ ),
210
+ "suggested_tool": "send_canary_cohort",
211
+ "suggested_args": {
212
+ "channel": delta.channel,
213
+ "recipients": f"canary_cohort_{canary_size}",
214
+ "canary_enabled": True,
215
+ "canary_size": canary_size,
216
+ "suppress_unsubscribes": True,
217
+ "idempotency_key": None,
218
+ },
219
+ }
220
+ return circuit_breaker.resolve(delta.natural_key, delta.confidence, base_steer)
221
+
222
+ if delta.canary_cohort_size > 0 and (
223
+ delta.predicted_bounce_rate > self.canary_max_bounce_rate
224
+ or delta.predicted_complaint_rate > self.canary_max_complaint_rate
225
+ ):
226
+ base_steer = {
227
+ "guidance": (
228
+ f"Canary cohort predicts {delta.predicted_bounce_rate:.1%} bounce rate and "
229
+ f"{delta.predicted_complaint_rate:.2%} complaint rate, exceeding safe thresholds. "
230
+ f"Recommend: Run list hygiene (remove hard bounces, re-engagement campaign) before full send."
231
+ ),
232
+ "suggested_tool": "run_list_hygiene",
233
+ "suggested_args": {
234
+ "segment": list(delta.segment_breakdown.keys())[0],
235
+ "remove_hard_bounces": True,
236
+ "reengagement_threshold_days": 90,
237
+ },
238
+ }
239
+ return circuit_breaker.resolve(delta.natural_key, delta.confidence, base_steer)
240
+
241
+ return EvaluationResult(
242
+ decision=GateDecision.ALLOW,
243
+ confidence=delta.confidence,
244
+ reason=f"Send to {delta.total_recipients} recipients is within safe autonomous operational boundary.",
245
+ )
246
+
247
+
248
+ def create_communication_gate_hook(
249
+ max_autonomous_recipients: int = 10000,
250
+ max_daily_send_limit: int = 100000,
251
+ canary_min_size: int = 100,
252
+ canary_max_bounce_rate: float = 0.05,
253
+ canary_max_complaint_rate: float = 0.01,
254
+ max_retries: int = 2,
255
+ context_provider=None,
256
+ ):
257
+ """
258
+ Factory for creating a communication gate simulator.
259
+
260
+ Usage:
261
+ simulator = create_communication_gate_hook(
262
+ max_autonomous_recipients=10000,
263
+ context_provider=lambda event: {
264
+ "segment_counts": get_segment_counts(event),
265
+ "recent_unsubscribes": get_recent_unsubscribes(event),
266
+ "historical_bounce_rate": 0.02,
267
+ "historical_complaint_rate": 0.005,
268
+ },
269
+ )
270
+ """
271
+ from ..integrations.strands_hook import ConsequenceGateHook
272
+
273
+ simulator = OutboundCommunicationSimulator(
274
+ max_autonomous_recipients=max_autonomous_recipients,
275
+ max_daily_send_limit=max_daily_send_limit,
276
+ canary_min_size=canary_min_size,
277
+ canary_max_bounce_rate=canary_max_bounce_rate,
278
+ canary_max_complaint_rate=canary_max_complaint_rate,
279
+ )
280
+ breaker = SteerCircuitBreaker(max_retries=max_retries)
281
+
282
+ def evaluator(delta, circuit_breaker):
283
+ return simulator.evaluate(delta, circuit_breaker)
284
+
285
+ return ConsequenceGateHook(
286
+ simulator_fn=simulator.simulate,
287
+ evaluator_fn=evaluator,
288
+ circuit_breaker=breaker,
289
+ context_provider=context_provider,
290
+ )
@@ -0,0 +1,165 @@
1
+ """
2
+ DataDeletionSimulator: outcome simulator for destructive DB operations.
3
+
4
+ Row-count estimation uses the database's own query planner (EXPLAIN, not
5
+ EXPLAIN ANALYZE -- so the query is never actually executed) rather than
6
+ hardcoded selectivity constants, which have no statistical grounding and
7
+ are demonstrably less accurate than even a real planner's known-imperfect
8
+ estimates. FK cascade detection walks the graph depth-first and
9
+ recursively, since ON DELETE CASCADE can compound across multiple hops.
10
+ """
11
+
12
+ from dataclasses import dataclass, field
13
+ from typing import Any, Dict, List, Optional, Set
14
+
15
+ from ..core.models import GateDecision, EvaluationResult
16
+ from ..core.circuit_breaker import SteerCircuitBreaker
17
+
18
+
19
+ @dataclass
20
+ class DeletionBlastDelta:
21
+ tool_name: str
22
+ target_table: str
23
+ estimated_affected_rows: int
24
+ has_unindexed_where_clause: bool
25
+ is_hard_delete: bool
26
+ has_active_foreign_key_cascades: bool
27
+ cascade_affected_tables: List[str]
28
+ irreversibility_score: float
29
+ confidence: float
30
+ natural_key: str
31
+ simulated_side_effects: List[str] = field(default_factory=list)
32
+
33
+
34
+ def get_planner_row_estimate(db_conn, table: str, filters: Dict[str, Any]) -> tuple:
35
+ """Returns (estimated_rows, used_index). Uses plain EXPLAIN so the
36
+ query is analyzed but never executed."""
37
+ if not filters:
38
+ where_clause = "TRUE"
39
+ params: List[Any] = []
40
+ else:
41
+ where_clause = " AND ".join(f"{k} = %s" for k in filters)
42
+ params = list(filters.values())
43
+
44
+ query = f"EXPLAIN (FORMAT JSON) SELECT 1 FROM {table} WHERE {where_clause}"
45
+ plan = db_conn.execute(query, params).fetchone()[0]
46
+ root = plan[0]["Plan"]
47
+ estimated_rows = root.get("Plan Rows", 0)
48
+ used_index = "Index" in root.get("Node Type", "")
49
+ return estimated_rows, used_index
50
+
51
+
52
+ def walk_fk_cascade_depth(db_conn, table: str, visited: Optional[Set[str]] = None) -> List[str]:
53
+ """Recursively walks ON DELETE CASCADE foreign keys depth-first."""
54
+ if visited is None:
55
+ visited = set()
56
+ if table in visited:
57
+ return []
58
+ visited.add(table)
59
+
60
+ rows = db_conn.execute(
61
+ """
62
+ SELECT tc.table_name
63
+ FROM information_schema.table_constraints tc
64
+ JOIN information_schema.referential_constraints rc
65
+ ON tc.constraint_name = rc.constraint_name
66
+ JOIN information_schema.constraint_column_usage ccu
67
+ ON rc.unique_constraint_name = ccu.constraint_name
68
+ WHERE ccu.table_name = %s AND rc.delete_rule = 'CASCADE'
69
+ """,
70
+ [table],
71
+ ).fetchall()
72
+
73
+ affected = [table]
74
+ for (child_table,) in rows:
75
+ affected.extend(walk_fk_cascade_depth(db_conn, child_table, visited))
76
+ return affected
77
+
78
+
79
+ class DataDeletionSimulator:
80
+ def __init__(self, max_autonomous_delete_rows: int = 100, db_conn=None):
81
+ self.max_autonomous_delete_rows = max_autonomous_delete_rows
82
+ self.db_conn = db_conn # optional live connection for EXPLAIN / FK introspection
83
+
84
+ def simulate(self, tool_name: str, args: Dict[str, Any],
85
+ context: Dict[str, Any]) -> DeletionBlastDelta:
86
+ table = args.get("table", "unknown")
87
+ filters = args.get("filters", {})
88
+ force_hard_delete = args.get("hard_delete", False)
89
+ natural_key = f"{table}:{sorted(filters.items())}"
90
+
91
+ table_stats = context.get("table_metadata", {}).get(table, {})
92
+ total_table_rows = table_stats.get("total_rows", 0)
93
+
94
+ if self.db_conn is not None:
95
+ estimated_rows, used_index = get_planner_row_estimate(self.db_conn, table, filters)
96
+ unindexed = not used_index
97
+ cascade_tables = walk_fk_cascade_depth(self.db_conn, table)[1:]
98
+ else:
99
+ estimated_rows = total_table_rows
100
+ unindexed = True
101
+ cascade_tables = table_stats.get("cascade_children", [])
102
+
103
+ has_cascades = len(cascade_tables) > 0
104
+ irreversibility = 1.0 if force_hard_delete else 0.2
105
+ confidence = 0.90 if self.db_conn is not None else 0.40
106
+
107
+ side_effects = []
108
+ if estimated_rows > self.max_autonomous_delete_rows:
109
+ side_effects.append(
110
+ f"Estimated row deletion ({estimated_rows}) exceeds safe autonomous threshold "
111
+ f"({self.max_autonomous_delete_rows})"
112
+ )
113
+ if has_cascades:
114
+ side_effects.append(
115
+ f"FK cascade will propagate to {len(cascade_tables)} additional table(s): {cascade_tables}"
116
+ )
117
+ if unindexed:
118
+ side_effects.append("Full table scan required; risk of lock escalation")
119
+
120
+ return DeletionBlastDelta(
121
+ tool_name=tool_name, target_table=table,
122
+ estimated_affected_rows=estimated_rows,
123
+ has_unindexed_where_clause=unindexed,
124
+ is_hard_delete=force_hard_delete,
125
+ has_active_foreign_key_cascades=has_cascades,
126
+ cascade_affected_tables=cascade_tables,
127
+ irreversibility_score=irreversibility, confidence=confidence,
128
+ natural_key=natural_key, simulated_side_effects=side_effects,
129
+ )
130
+
131
+ def evaluate(self, delta: DeletionBlastDelta,
132
+ circuit_breaker: SteerCircuitBreaker) -> EvaluationResult:
133
+ if delta.confidence < 0.70:
134
+ return EvaluationResult(
135
+ decision=GateDecision.ASK, confidence=delta.confidence,
136
+ reason="Missing live query-planner access; unable to calculate blast radius with confidence.",
137
+ )
138
+
139
+ if delta.estimated_affected_rows > (self.max_autonomous_delete_rows * 10) and delta.is_hard_delete:
140
+ return EvaluationResult(
141
+ decision=GateDecision.DENY, confidence=delta.confidence,
142
+ reason=f"CRITICAL BLAST RADIUS: hard delete would purge ~{delta.estimated_affected_rows:,} rows.",
143
+ )
144
+
145
+ if delta.estimated_affected_rows > self.max_autonomous_delete_rows or delta.is_hard_delete:
146
+ base_steer = {
147
+ "guidance": (
148
+ f"Direct hard deletion of {delta.estimated_affected_rows} rows from `{delta.target_table}` "
149
+ f"is restricted. Option A: `archive_and_soft_delete` (sets deleted_at, 30-day retention). "
150
+ f"Option B: if permanent deletion is required, call `request_bulk_purge_authorization`. "
151
+ f"Note: soft delete requires all downstream queries to filter deleted_at IS NULL."
152
+ ),
153
+ "suggested_tool": "archive_and_soft_delete",
154
+ "suggested_args": {
155
+ "table": delta.target_table,
156
+ "filters": {"retention_status": "expired"},
157
+ "mode": "soft_delete",
158
+ },
159
+ }
160
+ return circuit_breaker.resolve(delta.natural_key, delta.confidence, base_steer)
161
+
162
+ return EvaluationResult(
163
+ decision=GateDecision.ALLOW, confidence=delta.confidence,
164
+ reason=f"Delete operation is bounded (~{delta.estimated_affected_rows} rows) within safety envelope.",
165
+ )
@@ -0,0 +1,102 @@
1
+ """
2
+ FinancialDeltaPredictor: outcome simulator for disbursements, claims, refunds.
3
+ Sub-5ms evaluation budget (well within published MCP-proxy overhead norms).
4
+ """
5
+
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ from ..core.models import GateDecision, EvaluationResult
10
+ from ..core.circuit_breaker import SteerCircuitBreaker
11
+
12
+
13
+ @dataclass
14
+ class FinancialStateDelta:
15
+ tool_name: str
16
+ proposed_args: Dict[str, Any]
17
+ projected_net_delta_inr: float
18
+ rolling_24h_exposure_inr: float
19
+ policy_tier_limit_inr: float
20
+ irreversibility_score: float
21
+ confidence: float
22
+ natural_key: str
23
+ simulated_side_effects: List[str] = field(default_factory=list)
24
+
25
+
26
+ class FinancialDeltaPredictor:
27
+ def __init__(self, daily_tier_limit_inr: float = 25000.0,
28
+ instant_wire_threshold: float = 10000.0):
29
+ self.daily_tier_limit_inr = daily_tier_limit_inr
30
+ self.instant_wire_threshold = instant_wire_threshold
31
+
32
+ def simulate(self, tool_name: str, args: Dict[str, Any],
33
+ context: Dict[str, Any]) -> FinancialStateDelta:
34
+ amount = float(args.get("amount", 0.0))
35
+ currency = args.get("currency", "INR").upper()
36
+ payout_method = args.get("payout_method", "standard_ach")
37
+ natural_key = args.get("claim_id") or args.get("transaction_ref") or f"{tool_name}:{amount}"
38
+
39
+ conversion_rate = 1.0 if currency == "INR" else context.get("exchange_rates", {}).get(currency, 0.0)
40
+ net_amount_inr = amount * conversion_rate
41
+
42
+ current_24h_spend = context.get("account_rolling_24h_spend", 0.0)
43
+ projected_24h_spend = current_24h_spend + net_amount_inr
44
+
45
+ irreversibility = 1.0 if payout_method in ("instant_upi", "rtgs", "wire") else 0.4
46
+
47
+ has_verified_kyc = context.get("kyc_verified", False)
48
+ has_fresh_balance = "account_rolling_24h_spend" in context
49
+ confidence = 0.95 if (has_verified_kyc and has_fresh_balance) else 0.45
50
+
51
+ side_effects = []
52
+ if projected_24h_spend > self.daily_tier_limit_inr:
53
+ side_effects.append(
54
+ f"Exceeds tier velocity limit by INR {projected_24h_spend - self.daily_tier_limit_inr:,.2f}"
55
+ )
56
+ if net_amount_inr > self.instant_wire_threshold and irreversibility > 0.8:
57
+ side_effects.append("Irreversible transfer above single-transaction threshold")
58
+
59
+ return FinancialStateDelta(
60
+ tool_name=tool_name, proposed_args=args,
61
+ projected_net_delta_inr=net_amount_inr,
62
+ rolling_24h_exposure_inr=projected_24h_spend,
63
+ policy_tier_limit_inr=self.daily_tier_limit_inr,
64
+ irreversibility_score=irreversibility, confidence=confidence,
65
+ natural_key=natural_key, simulated_side_effects=side_effects,
66
+ )
67
+
68
+ def evaluate(self, delta: FinancialStateDelta,
69
+ circuit_breaker: SteerCircuitBreaker) -> EvaluationResult:
70
+ if delta.confidence < 0.70:
71
+ return EvaluationResult(
72
+ decision=GateDecision.ASK, confidence=delta.confidence,
73
+ reason="Low simulation confidence: stale ledger or unverified KYC context.",
74
+ )
75
+
76
+ if delta.rolling_24h_exposure_inr > (delta.policy_tier_limit_inr * 2.0) and delta.irreversibility_score >= 0.9:
77
+ return EvaluationResult(
78
+ decision=GateDecision.DENY, confidence=delta.confidence,
79
+ reason=f"Projected delta INR {delta.projected_net_delta_inr:,.2f} critically breaches velocity envelope.",
80
+ )
81
+
82
+ if delta.rolling_24h_exposure_inr > delta.policy_tier_limit_inr:
83
+ max_allowed_instant = max(0.0, delta.policy_tier_limit_inr -
84
+ (delta.rolling_24h_exposure_inr - delta.projected_net_delta_inr))
85
+ base_steer = {
86
+ "guidance": (
87
+ f"Cannot process full INR {delta.projected_net_delta_inr:,.2f} as instant disbursement. "
88
+ f"Option A: Process INR {max_allowed_instant:,.2f} instant + route remainder to dual-sign queue. "
89
+ f"Option B: Route entire claim to `submit_manual_review_ticket`."
90
+ ),
91
+ "suggested_tool": "create_staged_disbursement",
92
+ "suggested_args": {
93
+ "immediate_amount": max_allowed_instant,
94
+ "escrow_amount": delta.projected_net_delta_inr - max_allowed_instant,
95
+ },
96
+ }
97
+ return circuit_breaker.resolve(delta.natural_key, delta.confidence, base_steer)
98
+
99
+ return EvaluationResult(
100
+ decision=GateDecision.ALLOW, confidence=delta.confidence,
101
+ reason="Projected balance delta is within safe autonomous operational boundary.",
102
+ )