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.
- consequence_gate/__init__.py +3 -0
- consequence_gate/backtest/__init__.py +0 -0
- consequence_gate/backtest/harness.py +43 -0
- consequence_gate/backtest/reporter.py +20 -0
- consequence_gate/cli.py +75 -0
- consequence_gate/core/__init__.py +0 -0
- consequence_gate/core/circuit_breaker.py +56 -0
- consequence_gate/core/evaluator.py +27 -0
- consequence_gate/core/models.py +35 -0
- consequence_gate/integrations/__init__.py +0 -0
- consequence_gate/integrations/examples/run_langgraph.py +60 -0
- consequence_gate/integrations/examples/run_mcp_proxy.py +100 -0
- consequence_gate/integrations/langgraph_hook.py +197 -0
- consequence_gate/integrations/mcp_proxy.py +316 -0
- consequence_gate/integrations/strands_hook.py +224 -0
- consequence_gate/simulators/__init__.py +0 -0
- consequence_gate/simulators/communications.py +290 -0
- consequence_gate/simulators/database.py +165 -0
- consequence_gate/simulators/financial.py +102 -0
- consequence_gate-0.1.0.dist-info/METADATA +254 -0
- consequence_gate-0.1.0.dist-info/RECORD +24 -0
- consequence_gate-0.1.0.dist-info/WHEEL +5 -0
- consequence_gate-0.1.0.dist-info/entry_points.txt +2 -0
- consequence_gate-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
)
|