dosync 0.4.1__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.
dosync/policies.py ADDED
@@ -0,0 +1,1210 @@
1
+ """
2
+ DoSync — Policy Engine
3
+ ======================
4
+ Evaluates policies before intent execution.
5
+
6
+ A policy is a rule that can:
7
+ - ALLOW — intent executes normally
8
+ - BLOCK — intent is rejected with a reason
9
+ - CONFIRM — intent requires explicit confirmation before executing
10
+ - MODIFY — intent parameters are adjusted before execution
11
+
12
+ Policies are evaluated in priority order. First matching policy wins.
13
+
14
+ Example policies:
15
+ "never unlock doors after midnight"
16
+ "critical actions require confirmation"
17
+ "save_energy never turns off hallway lights"
18
+ "children cannot trigger away_mode"
19
+
20
+ Usage:
21
+ engine = PolicyEngine()
22
+ engine.add(NeverAfterHoursPolicy("unlock", hour_start=0, hour_end=6))
23
+ engine.add(RequireConfirmationPolicy(["lock", "unlock", "alarm"]))
24
+
25
+ result = engine.evaluate(intent, action_plan)
26
+ if result.decision == PolicyDecision.BLOCK:
27
+ # reject the intent
28
+ elif result.decision == PolicyDecision.CONFIRM:
29
+ # wait for confirmation before executing
30
+ """
31
+ from __future__ import annotations
32
+ import logging
33
+ from abc import ABC, abstractmethod
34
+ from dataclasses import dataclass, field
35
+ import threading
36
+ from collections import deque
37
+ import time
38
+ from datetime import datetime
39
+ from enum import Enum
40
+ from typing import TYPE_CHECKING
41
+
42
+ if TYPE_CHECKING:
43
+ from .models import Intent, ActionPlan, DeviceAction
44
+
45
+ log = logging.getLogger("dosync.policies")
46
+
47
+
48
+ # ── Policy decision ───────────────────────────────────────────────────────────
49
+
50
+ class PolicyDecision(str, Enum):
51
+ ALLOW = "allow" # proceed normally
52
+ BLOCK = "block" # reject the intent
53
+ CONFIRM = "confirm" # require explicit confirmation
54
+ MODIFY = "modify" # adjust parameters before execution
55
+
56
+
57
+ @dataclass
58
+ class PolicyResult:
59
+ decision: PolicyDecision
60
+ policy_name: str
61
+ reason: str = ""
62
+ modified_actions: list = field(default_factory=list)
63
+
64
+ @staticmethod
65
+ def allow(policy_name: str) -> "PolicyResult":
66
+ return PolicyResult(PolicyDecision.ALLOW, policy_name)
67
+
68
+ @staticmethod
69
+ def block(policy_name: str, reason: str) -> "PolicyResult":
70
+ log.warning("Policy BLOCK [%s]: %s", policy_name, reason)
71
+ return PolicyResult(PolicyDecision.BLOCK, policy_name, reason)
72
+
73
+ @staticmethod
74
+ def confirm(policy_name: str, reason: str) -> "PolicyResult":
75
+ log.info("Policy CONFIRM [%s]: %s", policy_name, reason)
76
+ return PolicyResult(PolicyDecision.CONFIRM, policy_name, reason)
77
+
78
+
79
+ # ── Base policy ───────────────────────────────────────────────────────────────
80
+
81
+ class BasePolicy(ABC):
82
+ """
83
+ Base class for all DoSync policies.
84
+
85
+ To implement a custom policy:
86
+ class MyPolicy(BasePolicy):
87
+ def evaluate(self, intent, plan) -> PolicyResult:
88
+ ...
89
+ """
90
+
91
+ @property
92
+ @abstractmethod
93
+ def name(self) -> str:
94
+ """Unique name for this policy."""
95
+ ...
96
+
97
+ @property
98
+ def priority(self) -> int:
99
+ """Lower number = evaluated first. Default 100."""
100
+ return 100
101
+
102
+ @property
103
+ def bypass_on_emergency(self) -> bool:
104
+ """Whether EMERGENCY urgency bypasses this policy.
105
+
106
+ Default: True — most safety policies should be bypassed for emergencies
107
+ (time restrictions, confirmation requirements, device exclusions).
108
+
109
+ Set to False for policies that represent absolute operator constraints
110
+ that must be honored even in emergencies (e.g. BlockIntentPolicy when
111
+ an operator has explicitly prohibited an intent class).
112
+
113
+ Note: IntentRateLimitPolicy and DeviceActuatorRateLimitPolicy handle
114
+ emergency bypass internally and do not rely on this flag.
115
+ """
116
+ return True
117
+
118
+ @abstractmethod
119
+ def evaluate(self, intent: "Intent", plan: "ActionPlan") -> PolicyResult | None:
120
+ """
121
+ Evaluate this policy against the intent and action plan.
122
+ Return None to abstain (policy does not apply to this intent).
123
+ Return a PolicyResult to make a decision.
124
+ """
125
+ ...
126
+
127
+
128
+ # ── Built-in policies ─────────────────────────────────────────────────────────
129
+
130
+ class NeverAfterHoursPolicy(BasePolicy):
131
+ """
132
+ Blocks specific actuator types outside allowed hours.
133
+
134
+ Example: never unlock doors between midnight and 6am.
135
+
136
+ NeverAfterHoursPolicy(
137
+ actuator_types=["unlock"],
138
+ blocked_hours_start=0,
139
+ blocked_hours_end=6,
140
+ reason="Security policy: no remote unlocking between 00:00 and 06:00"
141
+ )
142
+ """
143
+
144
+ def __init__(
145
+ self,
146
+ actuator_types: list[str],
147
+ blocked_hours_start: int,
148
+ blocked_hours_end: int,
149
+ reason: str = "",
150
+ ):
151
+ self._actuator_types = set(actuator_types)
152
+ self._start = blocked_hours_start
153
+ self._end = blocked_hours_end
154
+ self._reason = reason or (
155
+ f"Policy: {actuator_types} blocked between "
156
+ f"{blocked_hours_start:02d}:00 and {blocked_hours_end:02d}:00"
157
+ )
158
+
159
+ @property
160
+ def name(self) -> str:
161
+ return "never_after_hours"
162
+
163
+ @property
164
+ def priority(self) -> int:
165
+ return 10 # high priority
166
+
167
+ def evaluate(self, intent: "Intent", plan: "ActionPlan") -> PolicyResult | None:
168
+ from .models import Urgency
169
+ # Emergency always bypasses time restrictions
170
+ if intent.urgency == Urgency.EMERGENCY:
171
+ return None
172
+
173
+ now = datetime.now()
174
+ in_blocked_hours = self._start <= now.hour < self._end
175
+
176
+ if not in_blocked_hours:
177
+ return None # outside blocked window — policy does not apply
178
+
179
+ relevant = [a for a in plan.actions if a.action in self._actuator_types]
180
+ if not relevant:
181
+ return None # no relevant actions
182
+
183
+ return PolicyResult.block(
184
+ self.name,
185
+ f"{self._reason} (current time: {now.strftime('%H:%M')})"
186
+ )
187
+
188
+
189
+ class RequireConfirmationPolicy(BasePolicy):
190
+ """
191
+ Requires explicit confirmation for specific actuator types.
192
+
193
+ Example: always confirm before locking/unlocking doors.
194
+
195
+ RequireConfirmationPolicy(
196
+ actuator_types=["lock", "unlock", "alarm"],
197
+ reason="Critical action requires confirmation"
198
+ )
199
+ """
200
+
201
+ def __init__(self, actuator_types: list[str], reason: str = ""):
202
+ self._actuator_types = set(actuator_types)
203
+ self._reason = reason or f"Confirmation required for: {actuator_types}"
204
+
205
+ @property
206
+ def name(self) -> str:
207
+ return "require_confirmation"
208
+
209
+ @property
210
+ def priority(self) -> int:
211
+ return 20
212
+
213
+ def evaluate(self, intent: "Intent", plan: "ActionPlan") -> PolicyResult | None:
214
+ from .models import Urgency
215
+ # Emergency bypasses confirmation
216
+ if intent.urgency == Urgency.EMERGENCY:
217
+ return None
218
+
219
+ relevant = [a for a in plan.actions if a.action in self._actuator_types]
220
+ if not relevant:
221
+ return None
222
+
223
+ devices = [a.device_id for a in relevant]
224
+ return PolicyResult.confirm(
225
+ self.name,
226
+ f"{self._reason} — affects: {', '.join(devices)}"
227
+ )
228
+
229
+
230
+ class BlockIntentPolicy(BasePolicy):
231
+ """
232
+ Unconditionally blocks specific intents.
233
+
234
+ bypass_on_emergency=False: operator blocks are absolute — not bypassed
235
+ even by EMERGENCY urgency. If an operator has explicitly prohibited
236
+ an intent class, that prohibition is honored regardless of urgency.
237
+
238
+ Example: children cannot trigger away_mode.
239
+
240
+ BlockIntentPolicy(
241
+ intent_classes=["away_mode"],
242
+ actor_tags=["child"],
243
+ reason="Children cannot arm away mode"
244
+ )
245
+ """
246
+
247
+ @property
248
+ def bypass_on_emergency(self) -> bool:
249
+ return False # Operator blocks are absolute
250
+
251
+ def __init__(
252
+ self,
253
+ intent_classes: list[str],
254
+ reason: str = "",
255
+ actor_tags: list[str] | None = None,
256
+ ):
257
+ self._intents = set(intent_classes)
258
+ self._reason = reason or f"Intent blocked by policy: {intent_classes}"
259
+ self._actor_tags = set(actor_tags) if actor_tags else None
260
+
261
+ @property
262
+ def name(self) -> str:
263
+ return "block_intent"
264
+
265
+ @property
266
+ def priority(self) -> int:
267
+ return 5 # highest priority
268
+
269
+ def evaluate(self, intent: "Intent", plan: "ActionPlan") -> PolicyResult | None:
270
+ if intent.intent.value not in self._intents:
271
+ return None
272
+
273
+ if self._actor_tags:
274
+ actor = intent.context.get("actor_tags", [])
275
+ if not (self._actor_tags & set(actor)):
276
+ return None # actor doesn't match — policy doesn't apply
277
+
278
+ return PolicyResult.block(self.name, self._reason)
279
+
280
+
281
+ class DeviceExclusionPolicy(BasePolicy):
282
+ """
283
+ Excludes specific devices from specific intents.
284
+
285
+ Example: save_energy never turns off hallway lights.
286
+
287
+ DeviceExclusionPolicy(
288
+ intent_classes=["save_energy"],
289
+ excluded_device_ids=["wiz-hallway-01"],
290
+ reason="Hallway light stays on for safety"
291
+ )
292
+ """
293
+
294
+ def __init__(
295
+ self,
296
+ intent_classes: list[str],
297
+ excluded_device_ids: list[str],
298
+ reason: str = "",
299
+ bypass_on_emergency: bool = True,
300
+ ):
301
+ """
302
+ Args:
303
+ bypass_on_emergency: whether EMERGENCY urgency ignores this exclusion.
304
+
305
+ True (default, and the historical behavior) suits an exclusion
306
+ that expresses convenience: in a fire, use everything that helps.
307
+
308
+ False makes the exclusion ABSOLUTE. That is a real deployment
309
+ need, not an edge case: an operator may exclude a device because
310
+ involving it is USELESS or HARMFUL, and an emergency does not
311
+ change that — screens that nobody will read while evacuating, or
312
+ a ward where lights must never come on because of a
313
+ photosensitive patient. Whether an exclusion is advisory or
314
+ absolute is the DEPLOYER's judgement about their building, which
315
+ is precisely the kind of decision the protocol must not make for
316
+ them (2026-07-12 panel). Added 2026-07-14, when the first
317
+ operator ground truth ("screens must not act in an emergency")
318
+ turned out to be inexpressible.
319
+ """
320
+ self._intents = set(intent_classes)
321
+ self._excluded = set(excluded_device_ids)
322
+ self._reason = reason or f"Devices excluded by policy"
323
+ self._bypass_on_emergency = bypass_on_emergency
324
+
325
+ @property
326
+ def name(self) -> str:
327
+ return "device_exclusion"
328
+
329
+ @property
330
+ def priority(self) -> int:
331
+ return 30
332
+
333
+ @property
334
+ def bypass_on_emergency(self) -> bool:
335
+ return self._bypass_on_emergency
336
+
337
+ def evaluate(self, intent: "Intent", plan: "ActionPlan") -> PolicyResult | None:
338
+ if intent.intent.value not in self._intents:
339
+ return None
340
+
341
+ filtered = [a for a in plan.actions if a.device_id not in self._excluded]
342
+ excluded = [a for a in plan.actions if a.device_id in self._excluded]
343
+
344
+ if not excluded:
345
+ return None # no excluded devices in this plan
346
+
347
+ log.info("DeviceExclusionPolicy: removed %d action(s) for %s",
348
+ len(excluded), [a.device_id for a in excluded])
349
+
350
+ # MODIFY: return filtered plan
351
+ result = PolicyResult(
352
+ decision=PolicyDecision.MODIFY,
353
+ policy_name=self.name,
354
+ reason=self._reason,
355
+ modified_actions=filtered,
356
+ )
357
+ return result
358
+
359
+
360
+
361
+ # ── Device actuator rate limit policy ─────────────────────────────────────────
362
+
363
+ class DeviceActuatorRateLimitPolicy(BasePolicy):
364
+ """
365
+ Limits how many times a specific device can be targeted per minute.
366
+
367
+ Complements IntentRateLimitPolicy (which limits by intent source).
368
+ This policy limits by target device, preventing a single device
369
+ from being flooded with commands regardless of how many agents
370
+ or intents trigger it.
371
+
372
+ When the limit is exceeded for a device, that device's action is
373
+ removed from the ActionPlan (MODIFY). Other devices in the plan
374
+ are not affected.
375
+
376
+ Emergency intents are NEVER rate limited — protocol guarantee.
377
+
378
+ Default limit: 20 actions per minute per device.
379
+
380
+ Usage:
381
+ policy_engine.add(DeviceActuatorRateLimitPolicy())
382
+
383
+ # Custom limit
384
+ policy_engine.add(DeviceActuatorRateLimitPolicy(limit_per_minute=10))
385
+ """
386
+
387
+ DEFAULT_LIMIT = 20 # actions per minute per device
388
+
389
+ def __init__(
390
+ self,
391
+ limit_per_minute: int | None = None,
392
+ window_seconds: int = 60,
393
+ db=None,
394
+ ):
395
+ self._limit = limit_per_minute if limit_per_minute is not None else self.DEFAULT_LIMIT
396
+ self._window = window_seconds
397
+ self._db = db # DoSyncDB instance — if set, events are persisted
398
+ # Sliding window: {device_id: deque of timestamps}
399
+ self._windows: dict[str, deque] = {}
400
+ self._lock = threading.Lock()
401
+ # Restore windows from DB on startup if DB is available
402
+ if self._db is not None:
403
+ self._restore_from_db()
404
+
405
+ def set_db(self, db) -> None:
406
+ """Wire DB after construction (called from hub startup)."""
407
+ self._db = db
408
+ self._restore_from_db()
409
+
410
+ def _restore_from_db(self) -> None:
411
+ """Load rate limit events from DB on startup. Only loads events within current window."""
412
+ try:
413
+ events = self._db.load_rate_limit_events(self._window)
414
+ with self._lock:
415
+ for device_id, timestamps in events.items():
416
+ self._windows[device_id] = deque(sorted(timestamps))
417
+ log.info(
418
+ "DeviceActuatorRateLimitPolicy: restored %d device windows from DB",
419
+ len(events),
420
+ )
421
+ except Exception as exc:
422
+ log.warning("DeviceActuatorRateLimitPolicy: could not restore from DB: %s", exc)
423
+
424
+ @property
425
+ def name(self) -> str:
426
+ return "device_actuator_rate_limit"
427
+
428
+ @property
429
+ def priority(self) -> int:
430
+ return 5 # runs after IntentRateLimitPolicy (0) but before other policies
431
+
432
+ def evaluate(self, intent: "Intent", plan: "ActionPlan") -> PolicyResult | None:
433
+ from .models import Urgency
434
+
435
+ # Emergency intents are NEVER rate limited — protocol guarantee
436
+ if intent.urgency == Urgency.EMERGENCY:
437
+ return None
438
+
439
+ if not plan.actions:
440
+ return None
441
+
442
+ now = time.time()
443
+ cutoff = now - self._window
444
+ throttled: list[str] = []
445
+ allowed_actions = []
446
+
447
+ with self._lock:
448
+ for action in plan.actions:
449
+ device_id = action.device_id
450
+
451
+ if device_id not in self._windows:
452
+ self._windows[device_id] = deque()
453
+ window = self._windows[device_id]
454
+
455
+ # Evict expired timestamps
456
+ while window and window[0] < cutoff:
457
+ window.popleft()
458
+
459
+ if len(window) >= self._limit:
460
+ throttled.append(device_id)
461
+ else:
462
+ window.append(now)
463
+ allowed_actions.append(action)
464
+ # Persist to DB (best-effort — never block execution)
465
+ if self._db is not None:
466
+ try:
467
+ self._db.append_rate_limit_event(device_id, now)
468
+ except Exception:
469
+ pass
470
+
471
+ if not throttled:
472
+ return None # all devices within limit
473
+
474
+ log.info(
475
+ "DeviceActuatorRateLimitPolicy: throttled %d device(s): %s",
476
+ len(throttled), throttled,
477
+ )
478
+
479
+ if not allowed_actions:
480
+ # Every device in the plan is throttled — block the entire intent
481
+ return PolicyResult.block(
482
+ self.name,
483
+ f"All {len(throttled)} device(s) in plan are rate limited "
484
+ f"({self._limit} actions/{self._window}s).",
485
+ )
486
+
487
+ # Partial throttle — remove over-limit devices, execute the rest
488
+ return PolicyResult(
489
+ decision=PolicyDecision.MODIFY,
490
+ policy_name=self.name,
491
+ reason=f"Throttled {len(throttled)} device(s): {throttled}",
492
+ modified_actions=allowed_actions,
493
+ )
494
+
495
+ def get_stats(self) -> dict:
496
+ """Return current action counts per device for monitoring."""
497
+ now = time.time()
498
+ cutoff = now - self._window
499
+ stats = {}
500
+ with self._lock:
501
+ for device_id, window in self._windows.items():
502
+ active = sum(1 for t in window if t >= cutoff)
503
+ stats[device_id] = {
504
+ "count": active,
505
+ "limit": self._limit,
506
+ "remaining": max(0, self._limit - active),
507
+ }
508
+ return stats
509
+
510
+
511
+ # ── Policy Engine ─────────────────────────────────────────────────────────────
512
+
513
+ class IntentRateLimitPolicy(BasePolicy):
514
+ """
515
+ Limits intent execution frequency per source using a sliding window counter.
516
+
517
+ This policy is a REQUIRED component of any DoSync-compliant deployment.
518
+ It protects the hub against runaway AI agents, malfunctioning automations,
519
+ and denial-of-service conditions.
520
+
521
+ Emergency intents are NEVER rate limited — this is a protocol-level guarantee.
522
+ All other urgency levels are limited independently per source.
523
+
524
+ Default limits (configurable):
525
+ info: 60 intents / minute per source
526
+ warning: 60 intents / minute per source
527
+ alert: 20 intents / minute per source
528
+
529
+ Usage:
530
+ policy_engine.add(IntentRateLimitPolicy())
531
+
532
+ # Custom limits
533
+ policy_engine.add(IntentRateLimitPolicy(
534
+ limits_per_minute={"info": 30, "warning": 30, "alert": 10}
535
+ ))
536
+
537
+ The response follows HTTP 429 semantics: BLOCK with reason including
538
+ the current count, the limit, and the seconds until the window resets.
539
+ Every blocked intent is logged in the tamper-evident audit trail.
540
+ """
541
+
542
+ # Protocol-defined minimum default limits.
543
+ # A compliant hub MUST enforce at least these limits.
544
+ DEFAULT_LIMITS: dict[str, int] = {
545
+ "info": 60,
546
+ "warning": 60,
547
+ "alert": 20,
548
+ # "emergency" is intentionally absent — always bypassed
549
+ }
550
+
551
+ def __init__(
552
+ self,
553
+ limits_per_minute: dict[str, int] | None = None,
554
+ window_seconds: int = 60,
555
+ ):
556
+ self._limits = limits_per_minute if limits_per_minute is not None else self.DEFAULT_LIMITS
557
+ self._window = window_seconds
558
+ # Sliding window: {source: {urgency_value: deque of timestamps}}
559
+ self._windows: dict[str, dict[str, deque]] = {}
560
+ self._lock = threading.Lock()
561
+
562
+ @property
563
+ def name(self) -> str:
564
+ return "intent_rate_limit"
565
+
566
+ @property
567
+ def priority(self) -> int:
568
+ return 0 # FIRST line of defense — runs before all other policies
569
+
570
+ def evaluate(self, intent: "Intent", plan: "ActionPlan") -> PolicyResult | None:
571
+ from .models import Urgency
572
+
573
+ # Emergency intents are NEVER rate limited — protocol guarantee
574
+ if intent.urgency == Urgency.EMERGENCY:
575
+ return None
576
+
577
+ urgency_value = str(intent.urgency.value)
578
+ limit = self._limits.get(urgency_value)
579
+ if limit is None:
580
+ return None # no limit configured for this urgency level
581
+
582
+ source = getattr(intent, "source", None) or "unknown"
583
+ now = time.time()
584
+ cutoff = now - self._window
585
+
586
+ with self._lock:
587
+ # Initialize per-source, per-urgency sliding window
588
+ if source not in self._windows:
589
+ self._windows[source] = {}
590
+ if urgency_value not in self._windows[source]:
591
+ self._windows[source][urgency_value] = deque()
592
+
593
+ window = self._windows[source][urgency_value]
594
+
595
+ # Evict timestamps outside the sliding window
596
+ while window and window[0] < cutoff:
597
+ window.popleft()
598
+
599
+ current_count = len(window)
600
+
601
+ if current_count >= limit:
602
+ # Calculate retry-after: seconds until oldest entry leaves the window
603
+ retry_after = max(1, int(self._window - (now - window[0])) + 1)
604
+ return PolicyResult.block(
605
+ self.name,
606
+ f"Rate limit exceeded for source '{source}': "
607
+ f"{current_count}/{limit} {urgency_value} intents "
608
+ f"in the last {self._window}s. "
609
+ f"Retry after {retry_after}s."
610
+ )
611
+
612
+ # Record this intent execution
613
+ window.append(now)
614
+ return None # within limit — allow
615
+
616
+ def get_stats(self) -> dict:
617
+ """Return current rate limit counters for all sources. Useful for monitoring."""
618
+ now = time.time()
619
+ cutoff = now - self._window
620
+ stats = {}
621
+ with self._lock:
622
+ for source, urgency_windows in self._windows.items():
623
+ stats[source] = {}
624
+ for urgency, window in urgency_windows.items():
625
+ # Count active entries
626
+ active = sum(1 for t in window if t >= cutoff)
627
+ limit = self._limits.get(urgency, 0)
628
+ stats[source][urgency] = {
629
+ "count": active,
630
+ "limit": limit,
631
+ "remaining": max(0, limit - active),
632
+ }
633
+ return stats
634
+
635
+
636
+ class PolicyEngine:
637
+ """
638
+ Evaluates all registered policies against an intent and action plan.
639
+
640
+ Policies are evaluated in priority order (lowest number first).
641
+ First BLOCK or CONFIRM result wins.
642
+ MODIFY policies are accumulated — all matching MODIFY policies apply.
643
+ ALLOW is the default if no policy matches.
644
+
645
+ Usage:
646
+ engine = PolicyEngine()
647
+ engine.add(NeverAfterHoursPolicy(["unlock"], 0, 6))
648
+ engine.add(RequireConfirmationPolicy(["alarm"]))
649
+
650
+ result = engine.evaluate(intent, plan)
651
+ """
652
+
653
+ def __init__(self):
654
+ self._policies: list[BasePolicy] = []
655
+
656
+ def add(self, policy: BasePolicy) -> None:
657
+ """Register a policy."""
658
+ self._policies.append(policy)
659
+ self._policies.sort(key=lambda p: p.priority)
660
+ log.info("Policy registered: %s (priority %d)", policy.name, policy.priority)
661
+
662
+ def remove(self, policy_name: str) -> None:
663
+ """Remove a policy by name."""
664
+ self._policies = [p for p in self._policies if p.name != policy_name]
665
+
666
+ def list_policies(self) -> list[dict]:
667
+ """List all registered policies."""
668
+ return [{"name": p.name, "priority": p.priority} for p in self._policies]
669
+
670
+ def evaluate(self, intent: "Intent", plan: "ActionPlan") -> PolicyResult:
671
+ """
672
+ Evaluate all policies. Returns the first blocking/confirming result,
673
+ or ALLOW if all policies pass. MODIFY policies are applied cumulatively.
674
+ """
675
+ from .models import Urgency
676
+
677
+ # Emergency intents bypass policies that declare bypass_on_emergency=True.
678
+ # Policies with bypass_on_emergency=False are still evaluated
679
+ # (e.g. BlockIntentPolicy — operator blocks are absolute).
680
+ if intent.urgency == Urgency.EMERGENCY:
681
+ non_bypassable = [p for p in self._policies if not p.bypass_on_emergency]
682
+ emergency_plan = plan
683
+ emergency_modified: list[str] = []
684
+ emergency_reasons: list[str] = []
685
+ if non_bypassable:
686
+ for policy in non_bypassable:
687
+ try:
688
+ result = policy.evaluate(intent, emergency_plan)
689
+ except Exception as e:
690
+ log.error("Policy '%s' raised an exception: %s", policy.name, e)
691
+ continue
692
+ if result is None:
693
+ continue
694
+ if result.decision == PolicyDecision.BLOCK:
695
+ log.info(
696
+ "PolicyEngine: EMERGENCY intent blocked by non-bypassable policy '%s': %s",
697
+ policy.name, result.reason,
698
+ )
699
+ return result
700
+ if result.decision == PolicyDecision.MODIFY:
701
+ # A non-bypassable policy's decision is honored WHATEVER it
702
+ # is — not only BLOCK. Until 2026-07-14 this branch
703
+ # evaluated the policy, received its MODIFY, and silently
704
+ # discarded it on the way to allow("emergency_bypass"): the
705
+ # deployer declared an absolute restriction, the engine
706
+ # asked the policy, got the answer, and threw it away.
707
+ # Nobody noticed because every MODIFY policy in the tree was
708
+ # bypassable, so the case never arose until an operator
709
+ # needed "these devices must not act, not even in an
710
+ # emergency" (a ward where lights must never come on, a
711
+ # screen nobody will read while evacuating).
712
+ from .models import ActionPlan
713
+ emergency_plan = ActionPlan(
714
+ intent_id=plan.intent_id,
715
+ actions=result.modified_actions,
716
+ urgency=plan.urgency,
717
+ )
718
+ emergency_modified.append(policy.name)
719
+ emergency_reasons.append(f"{policy.name}: {result.reason}")
720
+ log.info(
721
+ "PolicyEngine: EMERGENCY intent — %d/%d policies bypassed",
722
+ len(self._policies) - len(non_bypassable), len(self._policies),
723
+ )
724
+ if emergency_modified:
725
+ log.info("PolicyEngine: EMERGENCY plan modified by non-bypassable %s",
726
+ emergency_modified)
727
+ return PolicyResult(
728
+ decision=PolicyDecision.MODIFY,
729
+ policy_name=", ".join(emergency_modified),
730
+ # Carry each policy's OWN declared reason: for audit
731
+ # provenance, "why was this removed" is the operator's
732
+ # words, not this engine's generic phrasing.
733
+ reason="; ".join(emergency_reasons)
734
+ or "Plan modified by non-bypassable policies",
735
+ modified_actions=emergency_plan.actions,
736
+ )
737
+ return PolicyResult.allow("emergency_bypass")
738
+
739
+ current_plan = plan
740
+ modify_applied = []
741
+ modify_reasons: list[str] = []
742
+
743
+ for policy in self._policies:
744
+ try:
745
+ result = policy.evaluate(intent, current_plan)
746
+ except Exception as e:
747
+ log.error("Policy '%s' raised an exception: %s", policy.name, e)
748
+ continue
749
+
750
+ if result is None:
751
+ continue # policy abstained
752
+
753
+ if result.decision == PolicyDecision.BLOCK:
754
+ return result # stop immediately
755
+
756
+ if result.decision == PolicyDecision.CONFIRM:
757
+ return result # stop and request confirmation
758
+
759
+ if result.decision == PolicyDecision.MODIFY:
760
+ # Apply modification and continue evaluating
761
+ from .models import ActionPlan
762
+ current_plan = ActionPlan(
763
+ intent_id=plan.intent_id,
764
+ actions=result.modified_actions,
765
+ urgency=plan.urgency,
766
+ )
767
+ modify_applied.append(policy.name)
768
+ modify_reasons.append(f"{policy.name}: {result.reason}")
769
+
770
+ if modify_applied:
771
+ log.info("PolicyEngine: MODIFY applied by %s", modify_applied)
772
+ return PolicyResult(
773
+ decision=PolicyDecision.MODIFY,
774
+ policy_name=", ".join(modify_applied),
775
+ reason="; ".join(modify_reasons) or "Plan modified by policies",
776
+ modified_actions=current_plan.actions,
777
+ )
778
+
779
+ return PolicyResult.allow("no_policy_matched")
780
+
781
+
782
+ # ── Intent priority map ───────────────────────────────────────────────────────
783
+
784
+ INTENT_PRIORITY: dict[str, int] = {
785
+ # Priority 1 — Emergency (highest)
786
+ "ensure_safety": 1,
787
+ "alert_anomaly": 1,
788
+ # Priority 2 — Security
789
+ "control_access": 2,
790
+ # Priority 3 — Notification
791
+ "notify": 3,
792
+ # Domain-specific intents (e.g. children_arrived_home) fall through to default 99
793
+ # Priority 4 — Comfort
794
+ "set_environment": 4,
795
+ "morning_routine": 4,
796
+ "bedtime_routine": 4,
797
+ "remind_chore": 4,
798
+ "report_status": 4,
799
+ # Priority 5 — Efficiency (lowest)
800
+ "save_energy": 5,
801
+ "away_mode": 5,
802
+ }
803
+
804
+ def get_intent_priority(intent_value: str) -> int:
805
+ """Returns priority for an intent. Lower = higher priority. Default 99."""
806
+ return INTENT_PRIORITY.get(intent_value, 99)
807
+
808
+
809
+ # ── Conflict resolution policy ────────────────────────────────────────────────
810
+
811
+ class ConflictResolutionPolicy(BasePolicy):
812
+ """
813
+ Detects and resolves conflicts between simultaneous intents.
814
+
815
+ When two intents affect the same devices simultaneously, the one
816
+ with higher priority (lower number) wins. The lower priority intent
817
+ is blocked or has conflicting actions removed.
818
+
819
+ Priority scale (lower = higher priority):
820
+ 1 = Emergency
821
+ 2 = Security
822
+ 3 = Presence
823
+ 4 = Comfort
824
+ 5 = Efficiency
825
+
826
+ Example:
827
+ notify (priority 3) fires at the same time as
828
+ save_energy (priority 5) — save_energy loses on shared devices.
829
+ """
830
+
831
+ def __init__(self, hub):
832
+ self._hub = hub
833
+
834
+ @property
835
+ def name(self) -> str:
836
+ return "conflict_resolution"
837
+
838
+ @property
839
+ def priority(self) -> int:
840
+ return 1 # evaluated first
841
+
842
+ def evaluate(self, intent: "Intent", plan: "ActionPlan") -> PolicyResult | None:
843
+ active = getattr(self._hub, "_active_intents", {})
844
+ if not active:
845
+ return None
846
+
847
+ current_priority = get_intent_priority(intent.intent.value)
848
+ current_devices = {a.device_id for a in plan.actions}
849
+
850
+ for active_intent_value, active_priority in active.items():
851
+ if active_intent_value == intent.intent.value:
852
+ continue
853
+
854
+ # Check if there are shared devices (conflict)
855
+ active_devices = getattr(self._hub, "_active_intent_devices", {}).get(
856
+ active_intent_value, set()
857
+ )
858
+ conflict_devices = current_devices & active_devices
859
+
860
+ if not conflict_devices:
861
+ continue
862
+
863
+ # Conflict detected
864
+ if current_priority > active_priority:
865
+ # Current intent has LOWER priority — block conflicting actions
866
+ filtered = [a for a in plan.actions if a.device_id not in conflict_devices]
867
+ if not filtered:
868
+ return PolicyResult.block(
869
+ self.name,
870
+ f"Intent '{intent.intent.value}' (priority {current_priority}) blocked "
871
+ f"by '{active_intent_value}' (priority {active_priority}) "
872
+ f"on devices: {conflict_devices}"
873
+ )
874
+ log.info(
875
+ "ConflictResolution: '%s' loses to '%s' on %d device(s)",
876
+ intent.intent.value, active_intent_value, len(conflict_devices)
877
+ )
878
+ result = PolicyResult(
879
+ decision=PolicyDecision.MODIFY,
880
+ policy_name=self.name,
881
+ reason=f"Conflict with higher-priority intent '{active_intent_value}'",
882
+ modified_actions=filtered,
883
+ )
884
+ return result
885
+
886
+ elif current_priority < active_priority:
887
+ # Current intent has HIGHER priority — it wins, log the override
888
+ log.info(
889
+ "ConflictResolution: '%s' (priority %d) overrides '%s' (priority %d) "
890
+ "on devices: %s",
891
+ intent.intent.value, current_priority,
892
+ active_intent_value, active_priority,
893
+ conflict_devices,
894
+ )
895
+ # No modification needed — current intent executes fully
896
+ return None
897
+
898
+ return None
899
+
900
+
901
+ # ── Contextual weighting policy ───────────────────────────────────────────────
902
+
903
+ class ContextualWeightingPolicy(BasePolicy):
904
+ """
905
+ Adjusts intent context based on temporal and environmental factors.
906
+
907
+ The same physical event carries different weight depending on context:
908
+ - A motion sensor at 3am is more likely an intrusion than at 3pm
909
+ - A temperature anomaly in winter has different implications than in summer
910
+ - Monday morning routines differ from weekend patterns
911
+
912
+ This policy injects a 'context_weight' into the intent context,
913
+ which the resolver can use to adjust scoring.
914
+
915
+ Weight scale:
916
+ 1.0 = normal weight (default)
917
+ > 1.0 = amplify response (e.g. motion at night = higher urgency)
918
+ < 1.0 = reduce response (e.g. motion during typical home hours = routine)
919
+
920
+ Built-in rules:
921
+ - motion_detected at night (22:00-06:00) → weight 1.8 (possible intrusion)
922
+ - motion_detected during work hours (09:00-17:00) weekday → weight 0.6 (likely routine)
923
+ - any intent on weekend → weight 0.9 (relaxed mode)
924
+ - temperature anomaly in extreme weather hours → weight 1.5
925
+ """
926
+
927
+ def __init__(self, custom_rules: list[dict] | None = None):
928
+ self._custom_rules = custom_rules or []
929
+
930
+ @property
931
+ def name(self) -> str:
932
+ return "contextual_weighting"
933
+
934
+ @property
935
+ def priority(self) -> int:
936
+ return 2 # evaluated very early, before conflict resolution
937
+
938
+ def _compute_weight(self, intent: "Intent") -> float:
939
+ now = datetime.now()
940
+ hour = now.hour
941
+ weekday = now.weekday() # 0=Monday, 6=Sunday
942
+ is_night = hour >= 22 or hour < 6
943
+ is_work_hours = 9 <= hour < 17 and weekday < 5
944
+ is_weekend = weekday >= 5
945
+ intent_value = intent.intent.value
946
+ trigger = intent.context.get("trigger", "")
947
+
948
+ weight = 1.0
949
+
950
+ # Motion at night — possible intrusion
951
+ if trigger == "motion_detected" and is_night:
952
+ weight = 1.8
953
+
954
+ # Motion during typical work hours on weekday — likely routine
955
+ elif trigger == "motion_detected" and is_work_hours:
956
+ weight = 0.6
957
+
958
+ # Weekend — relaxed mode
959
+ if is_weekend and intent_value not in ("ensure_safety", "alert_anomaly"):
960
+ weight *= 0.9
961
+
962
+ # Temperature anomaly at extreme hours
963
+ if trigger == "temperature_anomaly" and is_night:
964
+ weight = max(weight, 1.5)
965
+
966
+ # Apply custom rules
967
+ for rule in self._custom_rules:
968
+ if rule.get("intent") == intent_value:
969
+ if rule.get("hour_start") is not None and rule.get("hour_end") is not None:
970
+ if rule["hour_start"] <= hour < rule["hour_end"]:
971
+ weight = rule.get("weight", weight)
972
+
973
+ return round(weight, 2)
974
+
975
+ def evaluate(self, intent: "Intent", plan: "ActionPlan") -> PolicyResult | None:
976
+ from .models import Urgency
977
+ # Emergency always full weight
978
+ if intent.urgency == Urgency.EMERGENCY:
979
+ return None
980
+
981
+ weight = self._compute_weight(intent)
982
+
983
+ if weight == 1.0:
984
+ return None # no adjustment needed
985
+
986
+ # Inject weight into intent context for resolver awareness
987
+ intent.context["context_weight"] = weight
988
+
989
+ if weight < 0.7:
990
+ # Very low weight — reduce scope by keeping only high-scoring devices
991
+ log.info(
992
+ "ContextualWeighting: low weight %.2f for '%s' — reducing scope",
993
+ weight, intent.intent.value
994
+ )
995
+ high_priority_actions = plan.actions[:max(1, len(plan.actions) // 2)]
996
+ return PolicyResult(
997
+ decision=PolicyDecision.MODIFY,
998
+ policy_name=self.name,
999
+ reason=f"Low contextual weight ({weight}) — reduced scope",
1000
+ modified_actions=high_priority_actions,
1001
+ )
1002
+
1003
+ if weight > 1.5:
1004
+ # High weight — escalate urgency in context
1005
+ log.info(
1006
+ "ContextualWeighting: high weight %.2f for '%s' — escalating context",
1007
+ weight, intent.intent.value
1008
+ )
1009
+ intent.context["escalated"] = True
1010
+ intent.context["original_urgency"] = intent.urgency.value
1011
+
1012
+ log.info(
1013
+ "ContextualWeighting: weight=%.2f applied to '%s' (hour=%d, trigger=%s)",
1014
+ weight, intent.intent.value,
1015
+ __import__("datetime").datetime.now().hour,
1016
+ intent.context.get("trigger", "none")
1017
+ )
1018
+ return None # let intent proceed with modified context
1019
+
1020
+
1021
+ # ── Aerial domain policies (drone safety) ─────────────────────────────────────
1022
+ # These two policies are the ones the expert panel identified as the essential
1023
+ # barriers for a vehicle that moves through physical space. They are deliberately
1024
+ # ABSOLUTE: both set bypass_on_emergency = False. A geofence protects something
1025
+ # real (an airport, a populated area, restricted airspace); a DoSync "emergency"
1026
+ # is never a reason to fly the vehicle there. And once a human has taken manual
1027
+ # control, an emergency must NOT wrestle it back — the takeover is very likely the
1028
+ # response to that emergency.
1029
+ #
1030
+ # CRITICAL BOUNDARY: these policies filter INTENT before DoSync dispatches a MAVLink
1031
+ # command. They are NOT the flight safety system. The failsafe (lost link, low
1032
+ # battery, GPS loss) lives in the vehicle firmware and acts on its own, even if
1033
+ # DoSync does not exist. DoSync is a policy layer above the firmware, never the
1034
+ # firmware's replacement. The firmware also has its own geofence; this complements
1035
+ # it, it does not substitute for it.
1036
+
1037
+
1038
+ class GeofencePolicy(BasePolicy):
1039
+ """Blocks any go_to whose target lies outside the permitted perimeter.
1040
+
1041
+ The perimeter is a horizontal circle (center + max radius) plus a maximum
1042
+ altitude ceiling — the standard, easy-to-reason-about geofence that covers the
1043
+ overwhelming majority of cases. Each deployment configures its own perimeter
1044
+ via the constructor, exactly like NeverAfterHoursPolicy configures its hours.
1045
+ The protocol stays generic; the perimeter is deployment-specific.
1046
+
1047
+ Only go_to actions carry a destination, so only they are checked. take_off,
1048
+ land, return_home and loiter do not move the vehicle to an arbitrary point and
1049
+ are not constrained here (return_home in particular must always be allowed —
1050
+ it is how the vehicle comes back).
1051
+
1052
+ This is an ABSOLUTE limit: bypass_on_emergency is False. The perimeter exists
1053
+ because crossing it is dangerous or illegal, and no urgency changes that.
1054
+
1055
+ DESIGN NOTE — agnosticism and the deliberate coupling to `go_to`:
1056
+ The PolicyEngine itself stays fully domain-agnostic; this is just one more
1057
+ pluggable BasePolicy, registered only by deployments that fly something, like
1058
+ NeverAfterHoursPolicy is registered only where time restrictions matter. It is
1059
+ agnostic to the drone make/model (it reasons over lat/lon/alt, which are
1060
+ universal), but it is inherently SPATIAL — an oven or a light has no position,
1061
+ so a geofence is meaningless for them. That is correct and expected: generic
1062
+ mechanism, domain-specific policy.
1063
+ However, this policy is currently coupled to the MAVLink adapter's vocabulary:
1064
+ it checks the literal action name "go_to" and the params "lat"/"lon"/"alt".
1065
+ That coupling is DELIBERATE for now. DoSync has exactly one spatial adapter
1066
+ (the drone). Abstracting "an action with a spatial destination" into a protocol-
1067
+ level concept that any spatial adapter (a rover, a boat, a 3D robotic arm) would
1068
+ declare is the right move ONLY once a SECOND spatial device exists to inform the
1069
+ abstraction — designing it from a single example risks the wrong abstraction
1070
+ (the same "don't abstract on one example" discipline applied to the
1071
+ execution_model vocabulary). When a second spatial adapter arrives, extract a
1072
+ declared "spatial destination" capability and have this policy operate on that
1073
+ instead of the hardcoded "go_to". Until then, the explicit coupling is the
1074
+ honest, simplest correct choice.
1075
+ """
1076
+
1077
+ def __init__(self, center_lat: float, center_lon: float,
1078
+ max_radius_m: float, max_altitude_m: float = None,
1079
+ applies_to_devices: list = None):
1080
+ """
1081
+ Args:
1082
+ center_lat, center_lon: center of the permitted circle (degrees).
1083
+ max_radius_m: maximum allowed distance from center, in meters.
1084
+ max_altitude_m: optional altitude ceiling, in meters. None = no ceiling.
1085
+ applies_to_devices: optional list of device_ids this geofence governs.
1086
+ None = applies to every go_to in the plan (single-perimeter deploy).
1087
+ """
1088
+ self._center_lat = center_lat
1089
+ self._center_lon = center_lon
1090
+ self._max_radius_m = max_radius_m
1091
+ self._max_altitude_m = max_altitude_m
1092
+ self._devices = set(applies_to_devices) if applies_to_devices else None
1093
+
1094
+ @property
1095
+ def name(self) -> str:
1096
+ return "geofence"
1097
+
1098
+ @property
1099
+ def priority(self) -> int:
1100
+ # Evaluate early — a geofence breach should be caught before lower-priority
1101
+ # conveniences even look at the plan.
1102
+ return 10
1103
+
1104
+ @property
1105
+ def bypass_on_emergency(self) -> bool:
1106
+ # ABSOLUTE. An emergency never licenses crossing the perimeter.
1107
+ return False
1108
+
1109
+ @staticmethod
1110
+ def _haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
1111
+ """Great-circle distance, delegated to the shared geo module so the formula
1112
+ lives in exactly one place (see dosync/geo.py). Kept as a thin wrapper for
1113
+ backward compatibility with any code/tests referencing it."""
1114
+ from .geo import haversine_m
1115
+ return haversine_m(lat1, lon1, lat2, lon2)
1116
+
1117
+ def evaluate(self, intent: "Intent", plan: "ActionPlan") -> PolicyResult | None:
1118
+ from .geo import is_within_perimeter
1119
+ for action in plan.actions:
1120
+ if action.action != "go_to":
1121
+ continue
1122
+ if self._devices is not None and action.device_id not in self._devices:
1123
+ continue
1124
+
1125
+ params = action.params or {}
1126
+ lat = params.get("lat")
1127
+ lon = params.get("lon")
1128
+ if lat is None or lon is None:
1129
+ # A go_to without coordinates is malformed; let the adapter reject
1130
+ # it. The geofence only judges destinations it can locate.
1131
+ continue
1132
+
1133
+ # Same shared rule the in-flight guard uses — admission vs monitoring.
1134
+ ok, reason = is_within_perimeter(
1135
+ lat, lon, self._center_lat, self._center_lon,
1136
+ self._max_radius_m,
1137
+ alt=params.get("alt"), max_altitude_m=self._max_altitude_m,
1138
+ )
1139
+ if not ok:
1140
+ return PolicyResult.block(self.name, f"go_to target {reason}")
1141
+
1142
+ return None # every go_to in the plan is within the perimeter
1143
+
1144
+
1145
+ class ManualControlActivePolicy(BasePolicy):
1146
+ """Blocks commands to a vehicle whose active operation is `interrupted` — i.e.
1147
+ a human has taken manual control.
1148
+
1149
+ This is the policy-level counterpart to the MANUAL_CONTROL_TAKEN telemetry
1150
+ event. Once the pilot has the sticks, DoSync must not try to re-dispatch the
1151
+ original (or any) command to that vehicle, or it would fight the human for
1152
+ control. The pilot took over for a reason DoSync cannot see — most likely to
1153
+ avoid something. DoSync goes quiet until a human explicitly returns control
1154
+ (by clearing/finishing the interrupted operation out of band).
1155
+
1156
+ Consults the hub for the vehicle's active operations. An operation in the
1157
+ INTERRUPTED state means a human is flying; any command in the plan targeting
1158
+ that device is blocked.
1159
+
1160
+ ABSOLUTE: bypass_on_emergency is False. An emergency is very likely the reason
1161
+ the human took control — it must never be used to wrestle control back.
1162
+ """
1163
+
1164
+ INTERRUPTED_STATE = "interrupted"
1165
+
1166
+ def __init__(self, hub):
1167
+ self._hub = hub
1168
+
1169
+ @property
1170
+ def name(self) -> str:
1171
+ return "manual_control_active"
1172
+
1173
+ @property
1174
+ def priority(self) -> int:
1175
+ # Evaluate very early — if a human is flying, nothing else about the plan
1176
+ # for that device matters.
1177
+ return 5
1178
+
1179
+ @property
1180
+ def bypass_on_emergency(self) -> bool:
1181
+ # ABSOLUTE. An emergency never wrestles control back from a human pilot.
1182
+ return False
1183
+
1184
+ def _devices_under_manual_control(self) -> set:
1185
+ """Return the set of device_ids whose active operation is interrupted."""
1186
+ try:
1187
+ active = self._hub.db.get_active_operations()
1188
+ except Exception:
1189
+ return set()
1190
+ return {
1191
+ o.get("device_id") for o in active
1192
+ if o.get("state") == self.INTERRUPTED_STATE and o.get("device_id")
1193
+ }
1194
+
1195
+ def evaluate(self, intent: "Intent", plan: "ActionPlan") -> PolicyResult | None:
1196
+ manual = self._devices_under_manual_control()
1197
+ if not manual:
1198
+ return None
1199
+
1200
+ blocked = [a for a in plan.actions if a.device_id in manual]
1201
+ if not blocked:
1202
+ return None
1203
+
1204
+ devices = sorted({a.device_id for a in blocked})
1205
+ return PolicyResult.block(
1206
+ self.name,
1207
+ f"Device(s) {devices} are under manual human control "
1208
+ f"(operation interrupted). DoSync will not dispatch commands until a "
1209
+ f"human returns control.",
1210
+ )