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/hub.py ADDED
@@ -0,0 +1,2923 @@
1
+ """
2
+ DoSync Hub — Capability Registry + Semantic Resolver
3
+ Layers 3 & 4 of the DoSync protocol stack
4
+ """
5
+
6
+ from __future__ import annotations
7
+ import asyncio
8
+ import hashlib
9
+ import json
10
+ import logging
11
+ import os
12
+ import threading
13
+ import time
14
+ try:
15
+ from dosync import metrics as _M
16
+ except Exception: # metrics is optional; never let it break the hub
17
+ _M = None
18
+ from typing import Callable, Optional
19
+
20
+ from .db import DoSyncDB
21
+ from dataclasses import dataclass, field
22
+ from .models import (
23
+ ActionPlan, ActionResult, ActuatorSpec, CapabilityManifest,
24
+ ContextSignalType, DeviceAction, DeviceEvent, FamilyProfile,
25
+ Intent, IntentClass, IntentResult, OccupancyState, Phase,
26
+ PhasedActionPlan, PhaseAction, PresenceSignal, RoutineAction, Urgency,
27
+ )
28
+
29
+ log = logging.getLogger("dosync.hub")
30
+
31
+
32
+ # ── Capability Registry (Layer 3) ─────────────────────────────────────────────
33
+
34
+ class CapabilityRegistry:
35
+ """
36
+ Stores device manifests and answers capability queries.
37
+ In production this would persist to disk / SQLite.
38
+ """
39
+
40
+ def __init__(self):
41
+ self._devices: dict[str, CapabilityManifest] = {}
42
+ self._listeners: list[Callable] = []
43
+ # Inverted tag index for O(1) device lookup by tag.
44
+ # Maps tag -> set of device_ids that declare that tag.
45
+ self._tag_index: dict[str, set[str]] = {}
46
+ # Emergency-capable device ids for O(1) emergency lookup
47
+ self._emergency_ids: set[str] = set()
48
+
49
+ def register(self, manifest: CapabilityManifest) -> None:
50
+ old = self._devices.get(manifest.device_id)
51
+ if old:
52
+ for tag in old.tags:
53
+ self._tag_index.get(tag, set()).discard(manifest.device_id)
54
+ self._emergency_ids.discard(manifest.device_id)
55
+ self._devices[manifest.device_id] = manifest
56
+ for tag in manifest.tags:
57
+ if tag not in self._tag_index:
58
+ self._tag_index[tag] = set()
59
+ self._tag_index[tag].add(manifest.device_id)
60
+ if manifest.emergency_capable:
61
+ self._emergency_ids.add(manifest.device_id)
62
+ log.info("Registered device: %s (%s)", manifest.device_id, manifest.device_name)
63
+ for cb in self._listeners:
64
+ cb(manifest)
65
+
66
+ def unregister(self, device_id: str) -> None:
67
+ manifest = self._devices.pop(device_id, None)
68
+ if manifest:
69
+ for tag in manifest.tags:
70
+ self._tag_index.get(tag, set()).discard(device_id)
71
+ self._emergency_ids.discard(device_id)
72
+ log.info("Unregistered device: %s", device_id)
73
+
74
+ def get(self, device_id: str) -> Optional[CapabilityManifest]:
75
+ return self._devices.get(device_id)
76
+
77
+ def all(self) -> list[CapabilityManifest]:
78
+ return list(self._devices.values())
79
+
80
+ def find_by_tags(self, tags: list[str]) -> list[CapabilityManifest]:
81
+ """Return devices that have at least one of the given tags.
82
+ O(|tags| + |candidates|) with the inverted index, not O(n).
83
+ """
84
+ candidate_ids: set[str] = set()
85
+ for tag in tags:
86
+ candidate_ids |= self._tag_index.get(tag, set())
87
+ return [self._devices[did] for did in candidate_ids if did in self._devices]
88
+
89
+ def find_by_required_tags(self, required_tags: set[str]) -> list[CapabilityManifest]:
90
+ """Return devices that have ALL of the required tags (intersection index).
91
+ O(|result|) — starts with the smallest tag set and intersects progressively.
92
+ Significantly faster than union-based lookup when tags are specific.
93
+ """
94
+ if not required_tags:
95
+ return self.all()
96
+ # Sort by set size ascending — smallest set first minimizes intersection cost
97
+ sets = sorted(
98
+ [self._tag_index.get(t, set()) for t in required_tags],
99
+ key=len,
100
+ )
101
+ result_ids = sets[0].copy()
102
+ for s in sets[1:]:
103
+ result_ids &= s
104
+ if not result_ids:
105
+ break
106
+ return [self._devices[did] for did in result_ids if did in self._devices]
107
+
108
+ def find_emergency_capable(self) -> list[CapabilityManifest]:
109
+ """Return emergency-capable devices. O(|emergency_devices|) with index."""
110
+ return [self._devices[did] for did in self._emergency_ids if did in self._devices]
111
+
112
+ def find_by_actuator(self, actuator_type: str) -> list[CapabilityManifest]:
113
+ return [
114
+ d for d in self._devices.values()
115
+ if any(a.type == actuator_type for a in d.actuators)
116
+ ]
117
+
118
+ def on_register(self, cb: Callable) -> None:
119
+ self._listeners.append(cb)
120
+
121
+
122
+ # ── Semantic Resolver (Layer 4) ───────────────────────────────────────────────
123
+
124
+ # Maps intent classes to the tags and actuator types we look for
125
+ # ── Intent class resolution ──────────────────────────────────────────────────
126
+ # DoSync v0.4+: Intent classes are stored in the database, not hardcoded here.
127
+ # The protocol defines the FORMAT of an intent name, not its vocabulary.
128
+ # Resolution tags and actuators are registered via POST /v1/intent-classes.
129
+ # Universal intents (ensure_safety, alert_anomaly, control_access,
130
+ # report_status, notify) are seeded automatically at hub init.
131
+
132
+
133
+
134
+ # ── Resolver interface ────────────────────────────────────────────────────────
135
+
136
+ @dataclass
137
+ class ScoreBreakdown:
138
+ """v9: the structured result of ONE scoring computation, shared by the
139
+ decision path (resolve uses .total) and the explanation path (explain reads
140
+ the components). Because both consume this, the explanation cannot diverge
141
+ from the decision — the class that used to be two copies is now one."""
142
+ matched_tags: list
143
+ tag_component: float
144
+ location_component: float
145
+ emergency_component: float
146
+ matched_actuators: list
147
+ actuator_component: float
148
+ hard_filtered: bool
149
+ required_specific_tags: list
150
+ device_tags: list
151
+ had_any_tag_overlap: bool
152
+
153
+ @property
154
+ def total(self) -> float:
155
+ # The hard filter zeroes the score outright: an all-specific resolution
156
+ # with no overlap is OUT, bonuses notwithstanding (F3b).
157
+ if self.hard_filtered:
158
+ return 0.0
159
+ return (self.tag_component + self.location_component
160
+ + self.emergency_component + self.actuator_component)
161
+
162
+ def exclusion_reason(self) -> str:
163
+ if self.hard_filtered:
164
+ return (f"required specific tags {self.required_specific_tags} not in "
165
+ f"device tags {self.device_tags} (hard filter — bonuses do not apply)")
166
+ if not self.had_any_tag_overlap:
167
+ return "no tag overlap with intent resolution tags"
168
+ return "score = 0"
169
+
170
+
171
+ class BaseResolver:
172
+ """
173
+ Formal interface for DoSync semantic resolvers.
174
+
175
+ The protocol defines WHAT a resolver must do, not HOW.
176
+ Third-party implementations can be dropped in by subclassing this
177
+ and passing the instance to DoSyncHub.
178
+
179
+ A resolver receives an Intent and returns an ActionPlan.
180
+ It has read-only access to the CapabilityRegistry.
181
+
182
+ To implement a custom resolver:
183
+ class MyResolver(BaseResolver):
184
+ async def resolve(self, intent: Intent) -> ActionPlan:
185
+ ...
186
+ """
187
+
188
+ def __init__(self, registry: 'CapabilityRegistry'):
189
+ self.registry = registry
190
+
191
+ def resolve(self, intent: Intent) -> ActionPlan:
192
+ raise NotImplementedError(
193
+ 'Subclasses must implement resolve(intent) -> ActionPlan'
194
+ )
195
+
196
+
197
+ class ExternalResolver(BaseResolver):
198
+ """
199
+ HTTP-based external resolver. Delegates intent resolution to an external
200
+ service implementing the DoSync External Resolver Protocol.
201
+
202
+ The external service receives an Intent + the full CapabilityRegistry and
203
+ returns an ActionPlan. This enables resolvers implemented in any language
204
+ (Go, Node.js, Rust, etc.) or backed by a local LLM.
205
+
206
+ Configuration:
207
+ DOSYNC_RESOLVER_URL=http://my-resolver:8080
208
+
209
+ Wire format (POST /resolve):
210
+ Request body:
211
+ {
212
+ "intent": <Intent.to_dict()>,
213
+ "registry": [<CapabilityManifest.to_dict()>, ...],
214
+ "hub_id": "<hub identifier>"
215
+ }
216
+ Response body:
217
+ {
218
+ "intent_id": "<same intent_id from request>",
219
+ "urgency": "info" | "warning" | "alert" | "emergency",
220
+ "actions": [
221
+ {
222
+ "device_id": "<str>",
223
+ "action": "<str>",
224
+ "params": {},
225
+ "relevance_score": 0.0
226
+ }
227
+ ]
228
+ }
229
+ Timeout: 500ms (spec §6 requirement for non-LLM resolvers)
230
+
231
+ If the external resolver is unreachable or times out, falls back to
232
+ the CapabilityMatchingResolver automatically. Fallback is logged.
233
+
234
+ Full protocol definition: spec/RESOLVER-SPEC-v0.3.md §5 External Resolver
235
+ """
236
+
237
+ TIMEOUT_S = 0.5 # 500ms — spec requirement for non-LLM resolvers
238
+
239
+ def __init__(self, registry: 'CapabilityRegistry', url: str, hub_id: str = "",
240
+ hub: "DoSyncHub | None" = None):
241
+ super().__init__(registry)
242
+ self._url = url.rstrip("/") + "/resolve"
243
+ self._hub_id = hub_id
244
+ # Wiring contract (regression: tests/test_resolution_wiring.py): every
245
+ # resolver that reads intent resolutions needs a hub handle (_hub) for
246
+ # DB access — INCLUDING the local fallback. An unwired fallback returns
247
+ # empty resolutions for every intent, which is exactly the silent
248
+ # failure that gutted production explain()/fallback until 2026-07-11.
249
+ self._hub = hub
250
+ self._fallback = CapabilityMatchingResolver(registry)
251
+ self._fallback._hub = hub
252
+ log.info("ExternalResolver configured: %s (fallback: CapabilityMatchingResolver)", self._url)
253
+
254
+ def resolve(self, intent: Intent) -> ActionPlan:
255
+ """Resolve intent via external HTTP service.
256
+
257
+ Runs the blocking HTTP call in a ThreadPoolExecutor so it never
258
+ blocks the asyncio event loop, regardless of whether resolve() is
259
+ called from sync or async context.
260
+
261
+ Supports HTTPS: set DOSYNC_RESOLVER_URL=https://... and optionally
262
+ DOSYNC_RESOLVER_CA_CERT=/path/to/ca.crt for self-signed certificates.
263
+ """
264
+ import concurrent.futures
265
+ try:
266
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
267
+ future = pool.submit(self._http_resolve, intent)
268
+ return future.result(timeout=self.TIMEOUT_S + 1.0)
269
+ except concurrent.futures.TimeoutError:
270
+ log.warning("ExternalResolver timeout after %.1fs — falling back", self.TIMEOUT_S)
271
+ return self._fallback.resolve(intent)
272
+ except Exception as exc:
273
+ log.warning("ExternalResolver error (%s) — falling back", exc)
274
+ return self._fallback.resolve(intent)
275
+
276
+ def _http_resolve(self, intent: Intent) -> ActionPlan:
277
+ """Synchronous HTTP call — runs in thread pool, never in event loop."""
278
+ import json, ssl, urllib.request, urllib.error, os
279
+
280
+ payload = {
281
+ "intent": intent.to_dict(),
282
+ "registry": [m.to_dict() for m in self.registry.all()],
283
+ "hub_id": self._hub_id,
284
+ }
285
+ data = json.dumps(payload).encode()
286
+
287
+ # TLS context — use CA cert if provided, or system trust store
288
+ ctx = None
289
+ if self._url.startswith("https://"):
290
+ ctx = ssl.create_default_context()
291
+ ca_cert = os.environ.get("DOSYNC_RESOLVER_CA_CERT", "")
292
+ if ca_cert and os.path.exists(ca_cert):
293
+ ctx.load_verify_locations(ca_cert)
294
+
295
+ req = urllib.request.Request(
296
+ self._url,
297
+ data=data,
298
+ headers={"Content-Type": "application/json"},
299
+ method="POST",
300
+ )
301
+ try:
302
+ with urllib.request.urlopen(req, timeout=self.TIMEOUT_S, context=ctx) as resp:
303
+ body = json.loads(resp.read())
304
+ except urllib.error.URLError as exc:
305
+ raise RuntimeError(f"URLError: {exc}") from exc
306
+
307
+ # Parse response → ActionPlan
308
+ actions = [
309
+ DeviceAction(
310
+ device_id=a["device_id"],
311
+ action=a["action"],
312
+ params=a.get("params", {}),
313
+ relevance_score=float(a.get("relevance_score", 0.0)),
314
+ )
315
+ for a in body.get("actions", [])
316
+ ]
317
+ return ActionPlan(
318
+ intent_id=intent.intent_id,
319
+ actions=actions,
320
+ urgency=intent.urgency,
321
+ )
322
+
323
+ # Parse the response into an ActionPlan
324
+ try:
325
+ actions = [
326
+ DeviceAction(
327
+ device_id=a["device_id"],
328
+ action=a["action"],
329
+ params=a.get("params", {}),
330
+ relevance_score=float(a.get("relevance_score", 0.0)),
331
+ )
332
+ for a in body.get("actions", [])
333
+ ]
334
+ return ActionPlan(
335
+ intent_id=intent.intent_id,
336
+ actions=actions,
337
+ urgency=intent.urgency,
338
+ )
339
+ except (KeyError, TypeError, ValueError) as e:
340
+ log.warning("ExternalResolver returned invalid ActionPlan (%s) — falling back", e)
341
+ return self._fallback.resolve(intent)
342
+
343
+ def explain(self, intent: Intent) -> dict:
344
+ """
345
+ ExternalResolver delegates resolution to the external service.
346
+ For explain(), falls back to CapabilityMatchingResolver so the
347
+ scoring breakdown reflects the local tag-matching algorithm.
348
+ """
349
+ result = self._fallback.explain(intent)
350
+ result["resolver"] = "ExternalResolver"
351
+ result["external_url"] = self._url
352
+ # Preserve the fallback's semantic note (e.g. the read-only status-query
353
+ # explanation) — clobbering it hid WHY actuators don't fire. The wrapper
354
+ # context goes in its own field instead.
355
+ result["resolver_note"] = (
356
+ "Scoring shown is from local CapabilityMatchingResolver (fallback). "
357
+ "Actual resolution is delegated to the external service."
358
+ )
359
+ return result
360
+
361
+
362
+ class CapabilityMatchingResolver(BaseResolver):
363
+ """
364
+ Layer 4: resolves an Intent into an ActionPlan by matching
365
+ against registered device capabilities.
366
+ """
367
+
368
+ def __init__(self, registry: CapabilityRegistry):
369
+ self.registry = registry
370
+
371
+ # v9 (2026-07-21): scoring weights, named once. Previously these five
372
+ # constants were duplicated between _relevance_score (the decision) and
373
+ # explain (the story), with a comment promising they "must mirror exactly"
374
+ # — a promise the language did not enforce. Now there is one source.
375
+ _W_TAG = 10.0 # per overlapping tag
376
+ _W_LOCATION = 15.0 # context location matches a device tag
377
+ _W_EMERGENCY = 30.0 # emergency urgency + emergency_capable device
378
+ _W_ACTUATOR = 12.0 # per matching actuator type
379
+ _FORCED_SCORE = 50.0 # emergency force-inclusion floor (mirrors resolve())
380
+
381
+ def _score_breakdown(
382
+ self,
383
+ device: CapabilityManifest,
384
+ intent: Intent,
385
+ resolution: dict,
386
+ ) -> "ScoreBreakdown":
387
+ """The ONE scoring computation. Returns a structured breakdown whose
388
+ .total is the relevance score. Both the decision path (resolve, which
389
+ uses .total) and the explanation path (explain, which reads the parts)
390
+ consume THIS — they can no longer disagree, because there is nothing to
391
+ keep in sync."""
392
+ target_tags = set(resolution.get("tags", []))
393
+ device_tags = set(device.tags)
394
+ generic_tags = {"light", "climate", "communication", "sensor", "appliance", "display"}
395
+ specific_tags = target_tags - generic_tags
396
+ resolution_is_all_specific = bool(specific_tags) and not (target_tags & generic_tags)
397
+
398
+ matched_tags = target_tags & device_tags
399
+ # Hard-filter semantics (2026-07-11 panel decision, F3b): only when the
400
+ # resolution is ALL specific tags are those tags a requirement; mixed
401
+ # resolutions treat specific tags as boost, not gate.
402
+ hard_filtered = resolution_is_all_specific and not (specific_tags & device_tags)
403
+
404
+ location = intent.context.get("location", "")
405
+ location_hit = bool(location) and location in device_tags
406
+
407
+ emergency_hit = (intent.urgency == Urgency.EMERGENCY and device.emergency_capable)
408
+
409
+ target_actuators = set(resolution.get("actuators", []))
410
+ device_actuators = {a.type for a in device.actuators}
411
+ matched_actuators = target_actuators & device_actuators
412
+
413
+ return ScoreBreakdown(
414
+ matched_tags=sorted(matched_tags),
415
+ tag_component=len(matched_tags) * self._W_TAG,
416
+ location_component=self._W_LOCATION if location_hit else 0.0,
417
+ emergency_component=self._W_EMERGENCY if emergency_hit else 0.0,
418
+ matched_actuators=sorted(matched_actuators),
419
+ actuator_component=len(matched_actuators) * self._W_ACTUATOR,
420
+ hard_filtered=hard_filtered,
421
+ required_specific_tags=sorted(specific_tags) if hard_filtered else [],
422
+ device_tags=sorted(device_tags),
423
+ had_any_tag_overlap=bool(matched_tags),
424
+ )
425
+
426
+ def _relevance_score(
427
+ self,
428
+ device: CapabilityManifest,
429
+ intent: Intent,
430
+ resolution: dict,
431
+ ) -> float:
432
+ """Back-compat thin wrapper: the score is the breakdown's total."""
433
+ return self._score_breakdown(device, intent, resolution).total
434
+
435
+ def explain(self, intent: Intent) -> dict:
436
+ """
437
+ Explica el razonamiento del resolver para un intent dado.
438
+ Muestra el score de cada dispositivo y por qué fue incluido o excluido.
439
+ Calculado on-demand — refleja el estado actual del registry.
440
+ """
441
+ resolution = self._get_resolution(intent)
442
+ target_tags = set(resolution.get("tags", []))
443
+ target_actuators = set(resolution.get("actuators", []))
444
+ generic_tags = {"light", "climate", "communication", "sensor", "appliance", "display"}
445
+ specific_tags = target_tags - generic_tags
446
+ location = intent.context.get("location", "")
447
+
448
+ included = []
449
+ excluded = []
450
+
451
+ # Empty resolution = READ-ONLY status query — mirrors resolve() (F4a):
452
+ # the plan reads sensors on every sensing device; actuators never fire.
453
+ if not target_tags and not target_actuators:
454
+ for device in self.registry.all():
455
+ if device.sensors:
456
+ included.append({
457
+ "device_id": device.device_id,
458
+ "device_name": device.device_name,
459
+ "device_tags": sorted(device.tags),
460
+ "score": 1.0,
461
+ "score_breakdown": {"read_only_status_query": True,
462
+ "sensors": [sn.id for sn in device.sensors]},
463
+ "emergency_capable": device.emergency_capable,
464
+ "included": True,
465
+ })
466
+ else:
467
+ excluded.append({
468
+ "device_id": device.device_id,
469
+ "device_name": device.device_name,
470
+ "device_tags": sorted(device.tags),
471
+ "reason": "read-only status query — device has no sensors to read",
472
+ "included": False,
473
+ })
474
+ return {
475
+ "intent": intent.intent.value,
476
+ "urgency": intent.urgency.value,
477
+ "context": intent.context,
478
+ "resolution_tags": [],
479
+ "resolution_actuators": [],
480
+ "devices_evaluated": len(included) + len(excluded),
481
+ "devices_included": len(included),
482
+ "devices_excluded": len(excluded),
483
+ "included": included,
484
+ "excluded": excluded,
485
+ "note": ("Empty resolution: this intent is a read-only status query. "
486
+ "The plan is read_sensors on every sensing device; actuators never fire."),
487
+ }
488
+
489
+ for device in self.registry.all():
490
+ # v9: consume the SAME breakdown resolve() decides with. No recompute,
491
+ # nothing to keep in sync — the explanation IS the decision, narrated.
492
+ bd = self._score_breakdown(device, intent, resolution)
493
+ score = bd.total
494
+
495
+ # Emergency force-inclusion — mirrors resolve() (F2b): emergency_capable
496
+ # devices always participate in an emergency response, even when
497
+ # tags/actuators match nothing.
498
+ forced_emergency = (score == 0.0 and intent.urgency == Urgency.EMERGENCY
499
+ and device.emergency_capable)
500
+ if forced_emergency:
501
+ score = self._FORCED_SCORE
502
+
503
+ if score == 0:
504
+ excluded.append({
505
+ "device_id": device.device_id,
506
+ "device_name": device.device_name,
507
+ "device_tags": sorted(device.tags),
508
+ "reason": bd.exclusion_reason(),
509
+ "included": False,
510
+ })
511
+ else:
512
+ included.append({
513
+ "device_id": device.device_id,
514
+ "device_name": device.device_name,
515
+ "device_tags": sorted(device.tags),
516
+ "score": score,
517
+ "score_breakdown": {
518
+ "tag_overlap": bd.tag_component,
519
+ "matched_tags": bd.matched_tags,
520
+ "location_bonus": bd.location_component,
521
+ "emergency_bonus": bd.emergency_component,
522
+ "actuator_match": bd.actuator_component,
523
+ "matched_actuators": bd.matched_actuators,
524
+ "forced_emergency": forced_emergency,
525
+ },
526
+ "emergency_capable": device.emergency_capable,
527
+ "included": True,
528
+ })
529
+
530
+ # Sort included devices by descending relevance score
531
+ included.sort(key=lambda x: x["score"], reverse=True)
532
+
533
+ return {
534
+ "intent": intent.intent.value,
535
+ "urgency": intent.urgency.value,
536
+ "context": intent.context,
537
+ "resolution_tags": sorted(target_tags),
538
+ "resolution_actuators": sorted(target_actuators),
539
+ "devices_evaluated": len(included) + len(excluded),
540
+ "devices_included": len(included),
541
+ "devices_excluded": len(excluded),
542
+ "included": included,
543
+ "excluded": excluded,
544
+ "note": (
545
+ "This explanation reflects the resolver scoring only. "
546
+ "The PolicyEngine may further block, modify, or request confirmation "
547
+ "before execution. See the audit log for actual execution outcomes."
548
+ ),
549
+ }
550
+
551
+ def _profile_params(self, device: CapabilityManifest,
552
+ actuator_type: str, intent: Intent) -> dict | None:
553
+ """
554
+ Si el intent tiene acciones explicitas del FamilyProfile en el context,
555
+ busca los params correspondientes a este dispositivo y actuator.
556
+ Retorna None si no hay match — el caller usara los defaults.
557
+ """
558
+ profile_actions = intent.context.get("actions", [])
559
+ if not profile_actions:
560
+ return None
561
+ for pa in profile_actions:
562
+ # Match: device tag matches the action tag
563
+ # and actuator type matches
564
+ if (pa.get("action_type") == actuator_type and
565
+ pa.get("tag") in device.tags):
566
+ return pa.get("params", {})
567
+ return None
568
+
569
+ def _build_actions_for_device(
570
+ self,
571
+ device: CapabilityManifest,
572
+ intent: Intent,
573
+ resolution: dict,
574
+ ) -> list[DeviceAction]:
575
+ actions = []
576
+ target_actuators = set(resolution.get("actuators", []))
577
+
578
+ for actuator in device.actuators:
579
+ if not target_actuators or actuator.type in target_actuators:
580
+ # Prefer FamilyProfile params when available
581
+ profile_p = self._profile_params(device, actuator.type, intent)
582
+ params = profile_p if profile_p is not None else self._default_params(actuator, intent)
583
+ actions.append(DeviceAction(
584
+ device_id=device.device_id,
585
+ action=actuator.type,
586
+ params=params,
587
+ ))
588
+
589
+ # For sensors with no actuators, add a "read" action
590
+ if not actions and device.sensors:
591
+ actions.append(DeviceAction(
592
+ device_id=device.device_id,
593
+ action="read_sensors",
594
+ params={"sensor_ids": [s.id for s in device.sensors]},
595
+ ))
596
+
597
+ return actions
598
+
599
+ def _default_params(self, actuator: ActuatorSpec, intent: Intent) -> dict:
600
+ """Sensible defaults per actuator type."""
601
+ defaults = {
602
+ "unlock": {"duration_seconds": 300},
603
+ "lock": {},
604
+ "call": {"number": intent.context.get("emergency_number", "911"),
605
+ "message": intent.context.get("message", "Emergency at home")},
606
+ "notify": {"message": intent.context.get("message", ""),
607
+ "urgency": intent.urgency.value},
608
+ "alarm": {"pattern": "emergency" if intent.urgency == Urgency.EMERGENCY
609
+ else "alert"},
610
+ "light": {"brightness": 100, "color": "white"},
611
+ "set_brightness": {"brightness": 100},
612
+ "set_temperature": {"celsius": intent.context.get("target_temp", 21)},
613
+ }
614
+ return defaults.get(actuator.type, {})
615
+
616
+ def _get_resolution(self, intent: Intent) -> dict:
617
+ """Return resolution tags/actuators from the intent_classes DB table.
618
+ All intent classes — universal and domain-specific — live in the DB.
619
+ Falls back to empty resolution if intent class is not registered.
620
+ """
621
+ try:
622
+ # StateAwareResolver stores the hub as self._hub; some external/custom
623
+ # resolvers may expose it publicly as self.hub. Check both — this line
624
+ # being wrong ("hub" only) silently emptied EVERY intent resolution in
625
+ # production until 2026-07-11: the resolver ran purely on the
626
+ # emergency-capable bonus. Regression: tests/test_resolution_wiring.py
627
+ hub = getattr(self, "_hub", None) or getattr(self, "hub", None)
628
+ db = getattr(hub, "db", None)
629
+ if db:
630
+ name = str(intent.intent)
631
+ row = db.get_intent_class(name)
632
+ if row:
633
+ return {
634
+ "tags": row["resolution_tags"],
635
+ "actuators": row["resolution_actuators"],
636
+ }
637
+ except Exception as e:
638
+ log.warning("_get_resolution: DB lookup failed for '%s': %s", str(intent.intent), e)
639
+ return {"tags": [], "actuators": []}
640
+
641
+ def resolve(self, intent: Intent) -> ActionPlan:
642
+ from datetime import datetime
643
+ # Context validation: schedule-aware intents
644
+ schedule = intent.context.get("schedule")
645
+ if schedule:
646
+ now = datetime.now()
647
+ days_ok = now.weekday() < 5 # lun-vie = 0-4
648
+ hour_range = schedule.get("hour_range")
649
+ if hour_range:
650
+ h_start, h_end = hour_range
651
+ hour_ok = h_start <= now.hour * 60 + now.minute <= h_end
652
+ else:
653
+ hour_ok = True
654
+ if not days_ok or not hour_ok:
655
+ log.info("Intent '%s' blocked by schedule (day=%s hour=%s:%s)",
656
+ intent.intent.value, now.weekday(), now.hour, now.minute)
657
+ return ActionPlan(intent_id=intent.intent_id, actions=[], urgency=intent.urgency)
658
+ resolution = self._get_resolution(intent)
659
+
660
+ # Candidate selection via inverted tag index.
661
+ # Strategy:
662
+ # specific_tags → intersection index: O(|result|), devices must have ALL
663
+ # generic_tags only → union index: O(|tags| + |candidates|)
664
+ # no tags (report_status) → all devices
665
+ target_tags = set(resolution.get("tags", []))
666
+ generic_tags = {"light", "climate", "communication", "sensor", "appliance", "display"}
667
+ specific_tags = target_tags - generic_tags
668
+
669
+ if target_tags:
670
+ # Union index: candidates are devices with ANY of the target tags.
671
+ # O(|target_tags| + |candidates|) with the inverted index.
672
+ candidates = self.registry.find_by_tags(list(target_tags))
673
+ else:
674
+ # Empty resolution (e.g. report_status) = a READ-ONLY status query
675
+ # across the deployment (2026-07-11 panel decision, F4a): the plan is
676
+ # read_sensors on every device that senses — actuators never fire on
677
+ # a status query. Note: before this, the "all devices as candidates"
678
+ # branch was dead code — every candidate scored 0 and was dropped, so
679
+ # report_status had never produced a single action in production.
680
+ # SENSOR-KIND scope (2026-07-17): "read the environment" and "read
681
+ # every device's self-state" are different status questions, and the
682
+ # protocol now distinguishes them (SensorSpec.kind) while the
683
+ # DEPLOYMENT decides which one a bare status query means:
684
+ # * intent.context["scope"] wins per-query: "all" | "environment"
685
+ # * otherwise DOSYNC_STATUS_SCOPE (deployment config, same place
686
+ # the deployment declares its other preferences)
687
+ # * otherwise "all" — today's behavior, so nothing changes for a
688
+ # deployment that has expressed no preference. The protocol has
689
+ # no opinion; it only makes the question expressible.
690
+ # Invalid values warn and fall back rather than fail: a status query
691
+ # is read-only and harmless, and refusing it over a typo'd
692
+ # preference would be disproportionate.
693
+ scope = (intent.context or {}).get("scope")
694
+ if scope is not None and scope not in ("all", "environment"):
695
+ log.warning("status scope %r unknown — using deployment default", scope)
696
+ scope = None
697
+ if scope is None:
698
+ scope = os.environ.get("DOSYNC_STATUS_SCOPE", "all")
699
+ if scope not in ("all", "environment"):
700
+ log.warning("DOSYNC_STATUS_SCOPE=%r invalid — using 'all'", scope)
701
+ scope = "all"
702
+
703
+ read_actions = []
704
+ for d in self.registry.all():
705
+ if not d.sensors:
706
+ continue
707
+ sensor_ids = [
708
+ sn.id for sn in d.sensors
709
+ if scope == "all"
710
+ or getattr(sn, "kind", "environment") == "environment"
711
+ ]
712
+ if sensor_ids: # a device with only device_state sensors drops out
713
+ read_actions.append(DeviceAction(
714
+ device_id=d.device_id,
715
+ action="read_sensors",
716
+ params={"sensor_ids": sensor_ids},
717
+ ))
718
+ return ActionPlan(
719
+ intent_id=intent.intent_id,
720
+ actions=read_actions,
721
+ urgency=intent.urgency,
722
+ )
723
+
724
+ # Emergency intents: always include emergency_capable devices as candidates,
725
+ # even if their tags don't overlap with the intent's resolution tags.
726
+ # This ensures physical safety devices (lights at max brightness, alarms)
727
+ # are never excluded by the tag filter on emergency intents.
728
+ if intent.urgency == Urgency.EMERGENCY:
729
+ candidate_ids = {d.device_id for d in candidates}
730
+ for device in self.registry.find_emergency_capable():
731
+ if device.device_id not in candidate_ids:
732
+ candidates.append(device)
733
+
734
+ # Score candidates only
735
+ scored: list[tuple[float, CapabilityManifest]] = []
736
+ for device in candidates:
737
+ score = self._relevance_score(device, intent, resolution)
738
+ if score > 0:
739
+ scored.append((score, device))
740
+
741
+ scored.sort(key=lambda x: x[0], reverse=True)
742
+
743
+ # Emergency: always include ALL emergency-capable devices
744
+ forced_ids: set = set()
745
+ if intent.urgency == Urgency.EMERGENCY:
746
+ scored_ids = {d.device_id for _, d in scored}
747
+ for device in self.registry.find_emergency_capable():
748
+ if device.device_id not in scored_ids:
749
+ scored.append((50.0, device))
750
+ forced_ids.add(device.device_id)
751
+
752
+ # Build actions. F2b (2026-07-11 panel decision): at EMERGENCY urgency,
753
+ # an emergency_capable device whose resolution-scoped build yields ZERO
754
+ # actions falls back to its FULL capability set — a safety device that
755
+ # shows up and does nothing is worse than one that acts broadly. This
756
+ # covers both force-included devices and tag/bonus-scored devices whose
757
+ # actuator types simply don't appear in the resolution list. Tag and
758
+ # type devices correctly (TAG-VOCABULARY.md) for precise, resolution-
759
+ # scoped behavior instead. NOTE: the fallback builds every actuator the
760
+ # device declares — a deliberately blunt instrument for emergencies.
761
+ all_actions: list[DeviceAction] = []
762
+ for score, device in scored:
763
+ actions = self._build_actions_for_device(device, intent, resolution)
764
+ if (not actions and intent.urgency == Urgency.EMERGENCY
765
+ and device.emergency_capable):
766
+ actions = self._build_actions_for_device(
767
+ device, intent, {**resolution, "actuators": []})
768
+ for a in actions:
769
+ a.relevance_score = score
770
+ all_actions.extend(actions)
771
+
772
+ log.info(
773
+ "Intent '%s' resolved to %d actions across %d devices",
774
+ intent.intent.value, len(all_actions), len(scored),
775
+ )
776
+
777
+ return ActionPlan(
778
+ intent_id=intent.intent_id,
779
+ actions=all_actions,
780
+ urgency=intent.urgency,
781
+ )
782
+
783
+
784
+
785
+
786
+ # ── State-aware resolver ──────────────────────────────────────────────────────
787
+
788
+ class StateAwareResolver(CapabilityMatchingResolver):
789
+ """
790
+ Extends CapabilityMatchingResolver with device state awareness.
791
+
792
+ Before executing an action, queries the current device state
793
+ and skips redundant actions:
794
+ - Does not turn on a light that is already at full brightness
795
+ - Does not unlock a door that is already unlocked
796
+ - Does not set a temperature that is already at target
797
+
798
+ This is the recommended resolver for production deployments.
799
+ """
800
+
801
+ def __init__(self, registry: 'CapabilityRegistry', hub: 'DoSyncHub'):
802
+ super().__init__(registry)
803
+ self._hub = hub
804
+ self._state_cache: dict = {}
805
+
806
+ def _get_device_state(self, device_id: str) -> dict:
807
+ """Returns cached device state or empty dict if unknown."""
808
+ return self._state_cache.get(device_id, {})
809
+
810
+ def mark_unreachable(self, device_id: str, ttl_seconds: int = None) -> None:
811
+ """
812
+ Marks a device as unreachable for TTL seconds.
813
+ TTL is configured via DOSYNC_UNREACHABLE_TTL env var (default: 1800s = 30min).
814
+ Called when an adapter fails with a connection error.
815
+ """
816
+ import time as _time
817
+ import os as _os
818
+ ttl = ttl_seconds if ttl_seconds is not None else int(_os.environ.get("DOSYNC_UNREACHABLE_TTL", "1800"))
819
+ if device_id not in self._state_cache:
820
+ self._state_cache[device_id] = {}
821
+ self._state_cache[device_id]['unreachable'] = True
822
+ self._state_cache[device_id]['unreachable_until'] = _time.time() + ttl
823
+ self._state_cache[device_id]['unreachable_since'] = _time.time()
824
+ log.info('StateAwareResolver: device %s marked unreachable for %ds (TTL)', device_id, ttl)
825
+ try:
826
+ db = getattr(self._hub, 'db', None)
827
+ if db:
828
+ db.save_device_state(device_id, self._state_cache[device_id])
829
+ except Exception as _e:
830
+ log.warning('StateAwareResolver: failed to persist unreachable state for %s: %s', device_id, _e)
831
+
832
+ def clear_unreachable(self, device_id: str) -> None:
833
+ """Clears unreachable mark — called when device responds successfully."""
834
+ state = self._state_cache.get(device_id, {})
835
+ state.pop('unreachable', None)
836
+ state.pop('unreachable_until', None)
837
+ state.pop('unreachable_since', None)
838
+ self._state_cache[device_id] = state
839
+ log.info('StateAwareResolver: device %s unreachable mark cleared', device_id)
840
+
841
+ def update_state(self, device_id: str, state: dict) -> None:
842
+ """Called by adapters after execution to update state cache and persist to DB."""
843
+ if device_id not in self._state_cache:
844
+ self._state_cache[device_id] = {}
845
+ self._state_cache[device_id].update(state)
846
+ # Persist to SQLite to survive hub restarts
847
+ try:
848
+ db = getattr(self._hub, 'db', None)
849
+ if db:
850
+ db.save_device_state(device_id, self._state_cache[device_id])
851
+ except Exception as _e:
852
+ log.warning('StateAwareResolver: failed to persist state for %s: %s', device_id, _e)
853
+
854
+ # NOTE: start_background_refresh/_refresh_cycle used to live here. They moved
855
+ # to the hub (DoSyncHub.start_state_refresh) on 2026-07-14: server.py started
856
+ # them behind `isinstance(hub.resolver, StateAwareResolver)`, which is always
857
+ # False in production (ExternalResolver), so the refresher never ran. State
858
+ # refresh is a hub concern — the hub calls this resolver's update_state() to
859
+ # keep this cache coherent.
860
+
861
+ def _load_state_from_db(self) -> None:
862
+ """Load state cache from SQLite on startup. Silent if no data exists."""
863
+ try:
864
+ db = getattr(self._hub, 'db', None)
865
+ if db:
866
+ states = db.load_all_device_states()
867
+ if states:
868
+ self._state_cache.update(states)
869
+ log.info('StateAwareResolver: loaded state for %d device(s) from DB',
870
+ len(states))
871
+ except Exception as _e:
872
+ log.warning('StateAwareResolver: failed to load state from DB: %s', _e)
873
+
874
+ def _is_redundant(self, action: DeviceAction) -> bool:
875
+ """Returns True if the action would have no effect given current state."""
876
+ state = self._get_device_state(action.device_id)
877
+ if not state:
878
+ return False # unknown state — execute to be safe
879
+
880
+ if action.action == 'turn_on' and state.get('on') is True:
881
+ brightness = action.params.get('brightness', 100)
882
+ current_brightness = state.get('brightness', 0)
883
+ if current_brightness >= brightness:
884
+ log.debug('Skipping redundant turn_on for %s (already on at %s%%)',
885
+ action.device_id, current_brightness)
886
+ return True
887
+
888
+ if action.action == 'turn_off' and state.get('on') is False:
889
+ log.debug('Skipping redundant turn_off for %s (already off)', action.device_id)
890
+ return True
891
+
892
+ if action.action == 'unlock' and state.get('locked') is False:
893
+ log.debug('Skipping redundant unlock for %s (already unlocked)', action.device_id)
894
+ return True
895
+
896
+ if action.action == 'lock' and state.get('locked') is True:
897
+ log.debug('Skipping redundant lock for %s (already locked)', action.device_id)
898
+ return True
899
+
900
+ if action.action == 'set_temperature':
901
+ target = action.params.get('celsius')
902
+ current = state.get('temperature')
903
+ if target and current and abs(target - current) < 0.5:
904
+ log.debug('Skipping redundant set_temperature for %s (already at %.1f)',
905
+ action.device_id, current)
906
+ return True
907
+
908
+ return False
909
+
910
+ def resolve(self, intent: Intent) -> ActionPlan:
911
+ """Resolves intent and filters redundant actions based on device state."""
912
+ plan = super().resolve(intent)
913
+
914
+ if not self._state_cache:
915
+ return plan # no state info yet — pass through
916
+
917
+ filtered = [a for a in plan.actions if not self._is_redundant(a)]
918
+ skipped = len(plan.actions) - len(filtered)
919
+ if skipped:
920
+ log.info('StateAwareResolver: skipped %d redundant action(s) for intent %s',
921
+ skipped, intent.intent.value)
922
+
923
+ return ActionPlan(
924
+ intent_id=plan.intent_id,
925
+ actions=filtered,
926
+ urgency=plan.urgency,
927
+ )
928
+
929
+ # ── Audit log ─────────────────────────────────────────────────────────────────
930
+
931
+ class AuditLog:
932
+ """
933
+ Tamper-evident chained log for all intent executions.
934
+ SHA-256 chains each entry to the previous one.
935
+ """
936
+
937
+ def __init__(self):
938
+ self._entries: list[dict] = []
939
+ self._prev_hash = "0" * 64
940
+ self._persist_cb = None # set by DoSyncHub after db.init()
941
+ # AUDIT-ARCHIVE: where THIS chain begins. Genesis for a chain that has
942
+ # never been archived; the last archived entry's hash otherwise (set at
943
+ # restore from audit_meta). Verification starts here, not at genesis.
944
+ self.anchor_prev_hash = "0" * 64
945
+
946
+ def append(self, entry: dict) -> str:
947
+ entry["prev_hash"] = self._prev_hash
948
+ entry["timestamp"] = time.time()
949
+ raw = json.dumps(entry, sort_keys=True)
950
+ entry_hash = hashlib.sha256(raw.encode()).hexdigest()
951
+ entry["hash"] = entry_hash
952
+ self._prev_hash = entry_hash
953
+ self._entries.append(entry)
954
+ if self._persist_cb:
955
+ self._persist_cb(entry)
956
+ return entry_hash
957
+
958
+ def verify(self) -> bool:
959
+ prev = self.anchor_prev_hash
960
+ for entry in self._entries:
961
+ stored_hash = entry.pop("hash")
962
+ raw = json.dumps(entry, sort_keys=True)
963
+ calc = hashlib.sha256(raw.encode()).hexdigest()
964
+ entry["hash"] = stored_hash
965
+ if calc != stored_hash or entry["prev_hash"] != prev:
966
+ return False
967
+ prev = stored_hash
968
+ return True
969
+
970
+ def entries(self) -> list[dict]:
971
+ return list(self._entries)
972
+
973
+
974
+ # ── DoSync Hub ────────────────────────────────────────────────────────────────
975
+
976
+ class _TimedExecutor:
977
+ """Transparent executor wrapper that records per-action execution latency.
978
+
979
+ Delegates everything to the wrapped executor; only execute() is timed. Any
980
+ attribute the caller expects (release_claim, etc.) passes through. This keeps
981
+ latency instrumentation out of every execution path in the hub.
982
+ """
983
+
984
+ _dosync_timed = True
985
+
986
+ def __init__(self, inner, hub=None):
987
+ self._inner = inner
988
+ self._hub = hub
989
+
990
+ def __getattr__(self, name):
991
+ return getattr(self._inner, name)
992
+
993
+ async def execute(self, action, urgency):
994
+ _t0 = time.perf_counter()
995
+ result = await self._inner.execute(action, urgency)
996
+ success = bool(getattr(result, "success", False))
997
+ try:
998
+ if _M is not None:
999
+ _M.action_execution_seconds.observe(
1000
+ time.perf_counter() - _t0,
1001
+ {"result": "success" if success else "failed"},
1002
+ )
1003
+ except Exception:
1004
+ pass
1005
+ # Device health: every real action is a health signal, recorded at this
1006
+ # single chokepoint (all execution paths funnel through here) rather than
1007
+ # scattered across parallel/batch/retry paths. Two complementary sinks,
1008
+ # both previously built-but-unwired (no code populated them, so the
1009
+ # /v1/health endpoints returned empty in production):
1010
+ # 1. execution stats (db.device_health) — success-rate history per
1011
+ # device, feeding /v1/health/devices. record_execution's own
1012
+ # docstring says "call after each adapter.execute()"; this is it.
1013
+ # 2. passive reachability (hub.health) — reachable/unreachable + TTL.
1014
+ # A failed action records the failure but does NOT immediately mark a
1015
+ # device unreachable (a single command can fail transiently); only
1016
+ # execution-path timeouts do that. A success always refreshes reachable.
1017
+ try:
1018
+ if self._hub is not None and getattr(self._hub, "db", None) is not None:
1019
+ self._hub.db.record_execution(
1020
+ action.device_id, action.action, success,
1021
+ error=None if success else getattr(result, "error", None))
1022
+ except Exception:
1023
+ pass
1024
+ try:
1025
+ if self._hub is not None and getattr(self._hub, "health", None) is not None:
1026
+ if success:
1027
+ self._hub.health.mark_reachable(action.device_id)
1028
+ except Exception:
1029
+ pass
1030
+
1031
+ # INDEPENDENT-OBSERVATION (panel design 2026-07-21): if the action
1032
+ # declared a verify_with binding and it was ACCEPTED (success), read the
1033
+ # independent sensor and compare against the expected reading. This is
1034
+ # the difference between "the device accepted the command" (success) and
1035
+ # "an independent sensor confirms the effect happened" (verification).
1036
+ # Opt-in: no binding → result unchanged. Never affects success itself.
1037
+ binding = getattr(action, "verify_with", None)
1038
+ if binding is not None and success and self._hub is not None:
1039
+ try:
1040
+ result.verification = await self._verify_action(action, binding)
1041
+ except Exception as _ve:
1042
+ log.warning("verification raised for %s (recorded unverifiable): %s",
1043
+ action.device_id, _ve)
1044
+ from .models import VerificationResult, VerificationStatus
1045
+ result.verification = VerificationResult(
1046
+ status=VerificationStatus.UNVERIFIABLE, sensor_id=binding.sensor_id,
1047
+ expected=binding.expected_reading, observed=None,
1048
+ independence=("same_device" if binding.sensor_id.split(":")[0]
1049
+ == action.device_id else "independent_device"))
1050
+ return result
1051
+
1052
+ async def _verify_action(self, action, binding):
1053
+ """Read the verifying sensor and grade the outcome. Returns a
1054
+ VerificationResult with one of verified/contradicted/unverifiable."""
1055
+ import asyncio as _asyncio
1056
+ from .models import VerificationResult, VerificationStatus
1057
+
1058
+ from .adapters import AdapterExecutor
1059
+
1060
+ # sensor_id: "device_id:local_sensor" (cross-device) or "device_id".
1061
+ # A sensor on a DIFFERENT device than the actuator is genuine independent
1062
+ # observation (panel, Benítez); same-device is weaker evidence.
1063
+ if ":" in binding.sensor_id:
1064
+ sensor_device, sensor_key = binding.sensor_id.split(":", 1)
1065
+ else:
1066
+ sensor_device, sensor_key = binding.sensor_id, None
1067
+ independence = ("same_device" if sensor_device == action.device_id
1068
+ else "independent_device")
1069
+
1070
+ def _result(status, observed):
1071
+ return VerificationResult(
1072
+ status=status, sensor_id=binding.sensor_id,
1073
+ expected=binding.expected_reading, observed=observed,
1074
+ independence=independence)
1075
+
1076
+ # Read the verifying sensor through its adapter's get_state — the same
1077
+ # path the active probe uses. If we cannot reach one, the honest verdict
1078
+ # is UNVERIFIABLE (we could not look), NOT contradiction.
1079
+ inner = self._inner
1080
+ if not isinstance(inner, AdapterExecutor):
1081
+ return _result(VerificationStatus.UNVERIFIABLE, None)
1082
+
1083
+ device = self._hub.registry.get(sensor_device) if self._hub else None
1084
+ adapter = inner.get_adapter(device.adapter) if device else None
1085
+ if adapter is None or not hasattr(adapter, "get_state"):
1086
+ return _result(VerificationStatus.UNVERIFIABLE, None)
1087
+
1088
+ try:
1089
+ state = await _asyncio.wait_for(
1090
+ adapter.get_state(sensor_device), timeout=binding.deadline_s)
1091
+ except Exception:
1092
+ state = None
1093
+
1094
+ if state is None:
1095
+ return _result(VerificationStatus.UNVERIFIABLE, None)
1096
+
1097
+ observed = state.get(sensor_key) if (sensor_key and isinstance(state, dict)) else state
1098
+ if observed == binding.expected_reading:
1099
+ return _result(VerificationStatus.VERIFIED, observed)
1100
+ if sensor_key is None and isinstance(state, dict) \
1101
+ and binding.expected_reading in state.values():
1102
+ return _result(VerificationStatus.VERIFIED, state)
1103
+ return _result(VerificationStatus.CONTRADICTED, observed)
1104
+
1105
+
1106
+ class DeviceHealth:
1107
+ """Passive device health, owned by the HUB (not the resolver).
1108
+
1109
+ Health lived in StateAwareResolver until 2026-07-14, but production runs
1110
+ ExternalResolver (which lacks mark_unreachable), so the hub's
1111
+ `hasattr(resolver, "mark_unreachable")` guards silently no-op'd and NO device
1112
+ was ever marked unreachable in production — a false "everything healthy".
1113
+ Health is a property of the hub: it is populated from the EXECUTION PATH,
1114
+ which runs under any resolver, and persisted via the existing device_state
1115
+ table.
1116
+
1117
+ This is PASSIVE health: it reflects the outcome of the last real interaction
1118
+ with a device (an action executed or timed out), not an active heartbeat. It
1119
+ reports what it knows — last_seen, unreachable_since, until (TTL) — and never
1120
+ asserts "powered off" (it cannot distinguish an off device from a network
1121
+ drop). Active probing is future work (DEVICE-HEALTH-ACTIVE).
1122
+ """
1123
+
1124
+ DEFAULT_TTL_SECONDS = 300
1125
+
1126
+ def __init__(self, hub: "DoSyncHub"):
1127
+ self._hub = hub
1128
+ self._state: dict[str, dict] = {}
1129
+ self._lock = threading.Lock()
1130
+
1131
+ def _persist(self, device_id: str) -> None:
1132
+ try:
1133
+ db = getattr(self._hub, "db", None)
1134
+ if db:
1135
+ db.save_device_state(device_id, self._state[device_id])
1136
+ except Exception as e:
1137
+ log.warning("DeviceHealth: failed to persist state for %s: %s", device_id, e)
1138
+
1139
+ def mark_reachable(self, device_id: str) -> None:
1140
+ """A device responded — record last_seen and clear any unreachable mark."""
1141
+ with self._lock:
1142
+ st = self._state.get(device_id, {})
1143
+ st["last_seen"] = time.time()
1144
+ st.pop("unreachable", None)
1145
+ st.pop("unreachable_since", None)
1146
+ st.pop("unreachable_until", None)
1147
+ self._state[device_id] = st
1148
+ self._persist(device_id)
1149
+
1150
+ def record_heartbeat(self, device_id: str, reported: dict | None = None) -> None:
1151
+ """DEVICE-HEALTH-ACTIVE (b): a device proactively reported its own health.
1152
+
1153
+ This is PUSH health, complementing the hub's PULL probe. It matters for
1154
+ devices the hub cannot poll — behind NAT, sleeping, on networks that
1155
+ forbid inbound connections to the device — which can still assert
1156
+ liveness by reaching out. A heartbeat is POSITIVE SIGNAL, exactly like a
1157
+ successful probe or action: it marks the device reachable (clears any
1158
+ stale unreachable mark) and stamps last_heartbeat. It never marks a
1159
+ device unreachable — a device that stops sending heartbeats is simply a
1160
+ device we have not heard from, which is weaker evidence than an action
1161
+ timing out (the same asymmetry the passive path already preserves).
1162
+ """
1163
+ with self._lock:
1164
+ st = self._state.get(device_id, {})
1165
+ now = time.time()
1166
+ st["last_seen"] = now
1167
+ st["last_heartbeat"] = now
1168
+ if reported:
1169
+ # A device may volunteer structured self-report (battery, rssi,
1170
+ # firmware…). Stored verbatim under a namespaced key; the hub
1171
+ # takes no position on its contents — it is the device's word.
1172
+ st["heartbeat_report"] = reported
1173
+ st.pop("unreachable", None)
1174
+ st.pop("unreachable_since", None)
1175
+ st.pop("unreachable_until", None)
1176
+ self._state[device_id] = st
1177
+ self._persist(device_id)
1178
+
1179
+ def mark_unreachable(self, device_id: str, ttl_seconds: int | None = None) -> None:
1180
+ """A device did not respond to a real action — mark unreachable with TTL."""
1181
+ ttl = ttl_seconds if ttl_seconds is not None else self.DEFAULT_TTL_SECONDS
1182
+ with self._lock:
1183
+ st = self._state.get(device_id, {})
1184
+ now = time.time()
1185
+ st["unreachable"] = True
1186
+ st.setdefault("unreachable_since", now) # keep the first failure time
1187
+ st["unreachable_until"] = now + ttl
1188
+ self._state[device_id] = st
1189
+ self._persist(device_id)
1190
+
1191
+ def is_unreachable(self, device_id: str) -> bool:
1192
+ """True if marked unreachable and the TTL has not expired."""
1193
+ with self._lock:
1194
+ st = self._state.get(device_id)
1195
+ if not st or not st.get("unreachable"):
1196
+ return False
1197
+ if time.time() >= st.get("unreachable_until", 0):
1198
+ return False # TTL expired — treat as unknown/recovered
1199
+ return True
1200
+
1201
+ def snapshot(self, device_id: str) -> dict:
1202
+ """Honest per-device health. Never asserts 'off' — only what we observed."""
1203
+ with self._lock:
1204
+ st = dict(self._state.get(device_id, {}))
1205
+ unreachable = bool(st.get("unreachable")) and time.time() < st.get("unreachable_until", 0)
1206
+ return {
1207
+ "device_id": device_id,
1208
+ "reachable": (None if "last_seen" not in st and not unreachable
1209
+ else (not unreachable)),
1210
+ "last_seen": st.get("last_seen"),
1211
+ "last_heartbeat": st.get("last_heartbeat"),
1212
+ "heartbeat_report": st.get("heartbeat_report"),
1213
+ "unreachable_since": st.get("unreachable_since") if unreachable else None,
1214
+ "unreachable_until": st.get("unreachable_until") if unreachable else None,
1215
+ "note": ("no interaction recorded yet" if "last_seen" not in st and not unreachable
1216
+ else ("not responding to actions since the time shown (may be powered off "
1217
+ "or network-unreachable; passively observed)" if unreachable
1218
+ else ("last confirmed by a device-initiated heartbeat"
1219
+ if st.get("last_heartbeat") == st.get("last_seen")
1220
+ else "responded to its last action"))),
1221
+ }
1222
+
1223
+ # DEVICE-HEALTH-ACTIVE (c) — powered-off vs network-unreachable.
1224
+ # The honest core: for a UDP device (WiZ), a command timeout looks identical
1225
+ # whether the bulb has no power or its wifi dropped — UDP has no connection
1226
+ # ACK to tell them apart. So (c) does NOT guess the cause from the timeout
1227
+ # alone (that would be a workaround). It CROSS-REFERENCES the independent
1228
+ # signal we already collect — the device-initiated heartbeat (part b) — and
1229
+ # returns a verdict CALIBRATED WITH ITS EVIDENCE, admitting "indeterminate"
1230
+ # when the transport genuinely cannot tell. A device that heartbeat'd seconds
1231
+ # ago but ignores a command was alive just now (network/app issue, not
1232
+ # power); one silent for a long time AND unresponsive is more likely off; one
1233
+ # that never heartbeats at all leaves us honestly unable to say.
1234
+ _HEARTBEAT_FRESH_S = 90.0 # a heartbeat this recent means "was alive just now"
1235
+
1236
+ def reachability_assessment(self, device_id: str, now: float | None = None) -> dict:
1237
+ """Best available attribution for an unreachable device, with its
1238
+ evidence and a confidence level — never a bare guess."""
1239
+ now = now if now is not None else time.time()
1240
+ snap = self.snapshot(device_id)
1241
+ if snap["reachable"] is not False:
1242
+ return {"cause": "reachable", "confidence": "n/a",
1243
+ "evidence": "device is responding", "device_id": device_id}
1244
+
1245
+ last_hb = snap.get("last_heartbeat")
1246
+ hb_age = (now - last_hb) if last_hb else None
1247
+
1248
+ if hb_age is not None and hb_age < self._HEARTBEAT_FRESH_S:
1249
+ cause, conf, why = ("network_or_app", "high",
1250
+ f"heartbeat {hb_age:.0f}s ago but not responding to actions — the device "
1251
+ f"was alive just now, so this is a network or application fault, not power")
1252
+ elif last_hb is not None:
1253
+ cause, conf, why = ("likely_powered_off", "medium",
1254
+ f"no heartbeat for {hb_age:.0f}s and unresponsive — the device stopped "
1255
+ f"reaching out, consistent with loss of power (but a prolonged network "
1256
+ f"outage cannot be fully excluded)")
1257
+ else:
1258
+ cause, conf, why = ("indeterminate", "low",
1259
+ "device never sent a heartbeat, so power and network cannot be "
1260
+ "distinguished from a command timeout alone (transport limitation)")
1261
+ return {"cause": cause, "confidence": conf, "evidence": why,
1262
+ "device_id": device_id,
1263
+ "last_heartbeat_age_s": round(hb_age, 1) if hb_age is not None else None,
1264
+ "unreachable_since": snap.get("unreachable_since")}
1265
+
1266
+ def load_from_db(self) -> None:
1267
+ try:
1268
+ db = getattr(self._hub, "db", None)
1269
+ if db:
1270
+ with self._lock:
1271
+ self._state = {k: v for k, v in db.load_all_device_states().items()}
1272
+ except Exception as e:
1273
+ log.warning("DeviceHealth: failed to load state from db: %s", e)
1274
+
1275
+
1276
+ class DoSyncHub:
1277
+ """
1278
+ Main entry point for the DoSync protocol.
1279
+ Owns the registry, resolver, and audit log.
1280
+ Exposes async methods for device registration and intent execution.
1281
+ """
1282
+
1283
+ def __init__(self, db_path: str = "dosync.db"):
1284
+ self.registry = CapabilityRegistry()
1285
+ self.resolver = StateAwareResolver(self.registry, self)
1286
+ self.policy_engine = None # set via hub.policy_engine = PolicyEngine()
1287
+ self._active_intents: dict[str, int] = {} # intent_value -> priority
1288
+ self._active_intent_devices: dict[str, set] = {} # intent_value -> device_ids
1289
+ # v13 hygiene (parada técnica 2026-07-21, Paredes): progress_cb failures
1290
+ # are swallowed so an observer can't break execution — but swallowed !=
1291
+ # invisible. Count them so a real callback bug surfaces in /v1/status
1292
+ # instead of hiding in debug logs forever.
1293
+ self.progress_cb_failures: int = 0
1294
+ # Executor for HUB-INITIATED intents — those the hub raises on its own
1295
+ # (e.g. the capability-anomaly security alert), which have no caller to
1296
+ # supply one. Wired by the server at startup. If it is None the hub
1297
+ # cannot dispatch such an intent, and says so rather than failing quietly.
1298
+ self.default_executor = None
1299
+ self.audit_log = AuditLog()
1300
+ self.occupancy = OccupancyEngine()
1301
+ self.family_profile: FamilyProfile | None = None
1302
+ self._event_handlers: list[Callable] = []
1303
+ self.db = DoSyncDB(db_path)
1304
+ self.db.init()
1305
+ self.health = DeviceHealth(self) # hub-owned passive device health
1306
+ # Load persisted state now that db is ready
1307
+ if hasattr(self, "resolver"):
1308
+ self.resolver._load_state_from_db()
1309
+ self.health.load_from_db()
1310
+ self.audit_log._persist_cb = self.db.append_audit
1311
+ self._restore_from_db()
1312
+
1313
+ # ── Family profile ───────────────────────────────────────────────────────
1314
+
1315
+ # ── DB restore ──────────────────────────────────────────────────────────
1316
+
1317
+ async def start_state_refresh(
1318
+ self,
1319
+ executor: "DeviceExecutor",
1320
+ interval: float = None,
1321
+ ) -> None:
1322
+ """Hub-owned background state refresher — ACTIVE device health probing.
1323
+
1324
+ Periodically queries get_state() on every device whose adapter supports
1325
+ it, WITHOUT executing any action. Two effects:
1326
+ * hub.health.mark_reachable() on every responder — this is the active
1327
+ probing that makes recovery detectable within one interval, instead
1328
+ of waiting for the unreachable TTL to lapse or for some action to
1329
+ happen to succeed.
1330
+ * the resolver's own state cache is refreshed if it keeps one
1331
+ (StateAwareResolver.update_state), preserving redundancy detection
1332
+ for deployments that use it.
1333
+
1334
+ Until 2026-07-14 this lived on StateAwareResolver and server.py started
1335
+ it behind `isinstance(hub.resolver, StateAwareResolver)` — always False
1336
+ in production (which runs ExternalResolver), so it NEVER ran, silently,
1337
+ behind a log.debug. State refresh is a hub concern, not a resolver's:
1338
+ the same reasoning that moved device health to the hub.
1339
+
1340
+ POSITIVE SIGNAL ONLY, by deliberate design (preserved from the original):
1341
+ a device that does not answer get_state() is skipped, NOT marked
1342
+ unreachable. A failing get_state is weaker evidence than an action
1343
+ timing out (adapters implement it unevenly), and marking on weak
1344
+ evidence would manufacture false "dead device" reports.
1345
+
1346
+ Args:
1347
+ executor: AdapterExecutor to source adapters from
1348
+ interval: seconds between cycles. Defaults to the
1349
+ DOSYNC_STATE_REFRESH_INTERVAL env var (default 60).
1350
+ """
1351
+ if interval is None:
1352
+ interval = float(os.environ.get("DOSYNC_STATE_REFRESH_INTERVAL", "60"))
1353
+
1354
+ log.info("Hub: background state refresh started (interval=%.0fs)", interval)
1355
+
1356
+ while True:
1357
+ try:
1358
+ await asyncio.sleep(interval)
1359
+ await self._state_refresh_cycle(executor)
1360
+ except asyncio.CancelledError:
1361
+ log.info("Hub: background state refresh stopped")
1362
+ break
1363
+ except Exception as e:
1364
+ log.warning("Hub: state refresh cycle error: %s", e)
1365
+
1366
+ async def _state_refresh_cycle(self, executor: "DeviceExecutor") -> None:
1367
+ """One refresh cycle — probe every device whose adapter supports get_state()."""
1368
+ from .adapters import AdapterExecutor
1369
+ if not isinstance(executor, AdapterExecutor):
1370
+ return
1371
+
1372
+ refreshed = 0
1373
+ skipped = 0
1374
+ recovered: list[str] = []
1375
+
1376
+ for device in self.registry.all():
1377
+ adapter = executor.get_adapter(device.adapter)
1378
+ if adapter is None or not hasattr(adapter, "get_state"):
1379
+ skipped += 1
1380
+ continue
1381
+
1382
+ try:
1383
+ state = await asyncio.wait_for(
1384
+ adapter.get_state(device.device_id), timeout=3.0)
1385
+ except Exception:
1386
+ skipped += 1 # positive-signal only: no unreachable mark
1387
+ continue
1388
+
1389
+ if state is None:
1390
+ skipped += 1
1391
+ continue
1392
+
1393
+ # The device answered: it is reachable, right now, without us acting.
1394
+ was_unreachable = self.health.is_unreachable(device.device_id)
1395
+ self.health.mark_reachable(device.device_id)
1396
+ if was_unreachable:
1397
+ recovered.append(device.device_id)
1398
+
1399
+ # Keep a state-caching resolver coherent, if one is plugged in.
1400
+ _update = getattr(self.resolver, "update_state", None)
1401
+ if callable(_update):
1402
+ try:
1403
+ _update(device.device_id, state)
1404
+ except Exception as e:
1405
+ log.debug("Hub: resolver update_state failed for %s: %s",
1406
+ device.device_id, e)
1407
+ _clear = getattr(self.resolver, "clear_unreachable", None)
1408
+ if was_unreachable and callable(_clear):
1409
+ try:
1410
+ _clear(device.device_id)
1411
+ except Exception:
1412
+ pass
1413
+ refreshed += 1
1414
+
1415
+ for device_id in recovered:
1416
+ log.info("Hub: %s back online (detected by state refresh)", device_id)
1417
+ if refreshed:
1418
+ log.debug("Hub: state refresh done — %d probed, %d skipped, %d recovered",
1419
+ refreshed, skipped, len(recovered))
1420
+
1421
+ def _restore_from_db(self) -> None:
1422
+ """
1423
+ Al iniciar el hub, restaura el estado desde SQLite.
1424
+ Los dispositivos, perfil y audit log sobreviven reinicios.
1425
+ """
1426
+ from .models import (
1427
+ ActuatorSpec, CapabilityManifest, CertTier, ContextSignal,
1428
+ ContextSignalType, DeviceCategory, EventSpec, SensorSpec, Severity,
1429
+ )
1430
+
1431
+ # Restore devices
1432
+ for manifest_dict in self.db.load_devices():
1433
+ try:
1434
+ # Rebuild CapabilityManifest from persisted dict
1435
+ caps = manifest_dict.get("capabilities", {})
1436
+
1437
+ sensors = [
1438
+ SensorSpec(
1439
+ id=s["id"], type=s["type"],
1440
+ description=s.get("description", ""),
1441
+ unit=s.get("unit"),
1442
+ poll_interval_ms=s.get("poll_interval_ms", 30000),
1443
+ kind=s.get("kind", "environment"), # legacy manifests default
1444
+ )
1445
+ for s in caps.get("sensors", [])
1446
+ ]
1447
+ actuators = [
1448
+ ActuatorSpec(
1449
+ id=a["id"], type=a["type"],
1450
+ description=a.get("description", ""),
1451
+ )
1452
+ for a in caps.get("actuators", [])
1453
+ ]
1454
+ events = [
1455
+ EventSpec(
1456
+ id=e["id"],
1457
+ severity=Severity(e.get("severity", "info")),
1458
+ description=e.get("description", ""),
1459
+ )
1460
+ for e in caps.get("events", [])
1461
+ ]
1462
+ context_signals = [
1463
+ ContextSignal(
1464
+ type=ContextSignalType(c["type"]),
1465
+ description=c.get("description", ""),
1466
+ confidence_weight=c.get("confidence_weight", 1.0),
1467
+ )
1468
+ for c in caps.get("context_signals", [])
1469
+ ]
1470
+
1471
+ manifest = CapabilityManifest(
1472
+ device_id=manifest_dict["device_id"],
1473
+ device_name=manifest_dict["device_name"],
1474
+ manufacturer=manifest_dict["manufacturer"],
1475
+ model=manifest_dict["model"],
1476
+ firmware=manifest_dict["firmware"],
1477
+ category=DeviceCategory(manifest_dict["category"]),
1478
+ tags=manifest_dict["tags"],
1479
+ sensors=sensors,
1480
+ actuators=actuators,
1481
+ events=events,
1482
+ context_signals=context_signals,
1483
+ emergency_capable=manifest_dict.get("emergency_capable", False),
1484
+ cert_tier=CertTier(manifest_dict["cert_tier"]) if manifest_dict.get("cert_tier") else None,
1485
+ )
1486
+ # Restore adapter fields — critical for physical device control
1487
+ if manifest_dict.get("adapter"):
1488
+ manifest.adapter = manifest_dict["adapter"]
1489
+ manifest.adapter_config = manifest_dict.get("adapter_config", {})
1490
+ self.registry.register(manifest)
1491
+ except Exception as e:
1492
+ log.warning("Could not restore device %s: %s",
1493
+ manifest_dict.get("device_id", "?"), e)
1494
+
1495
+ # Restore audit log. If older entries were archived to a segment file,
1496
+ # the chain starts at the stored anchor, not at genesis — both the
1497
+ # append continuity (_prev_hash) and verification (anchor_prev_hash)
1498
+ # must honor it.
1499
+ _anchor = self.db.get_audit_anchor()
1500
+ if _anchor:
1501
+ self.audit_log.anchor_prev_hash = _anchor.get("anchor_prev_hash", "0" * 64)
1502
+ self.audit_log._prev_hash = self.audit_log.anchor_prev_hash
1503
+ for entry in self.db.load_audit_log():
1504
+ self.audit_log._entries.append(entry)
1505
+ self.audit_log._prev_hash = entry.get("hash", "0" * 64)
1506
+
1507
+ # Restore family profile. Until 2026-07-14 this was MISSING: the profile
1508
+ # was persisted by set_family_profile() and db.load_family_profile()
1509
+ # existed, but nothing ever called it — so every restart silently dropped
1510
+ # the profile while this method's docstring promised it survives.
1511
+ try:
1512
+ profile_dict = self.db.load_family_profile()
1513
+ if profile_dict:
1514
+ from .models import FamilyProfile
1515
+ self.family_profile = FamilyProfile.from_dict(profile_dict)
1516
+ log.info("Restored family profile: %s", self.family_profile.family_name)
1517
+ except Exception as e:
1518
+ log.warning("Could not restore family profile: %s", e)
1519
+
1520
+ # Restore presence signals
1521
+ from .models import PresenceSignal
1522
+ for signal_dict in self.db.load_presence_signals():
1523
+ try:
1524
+ signal = PresenceSignal(
1525
+ device_id=signal_dict["device_id"],
1526
+ signal_type=ContextSignalType(signal_dict["signal_type"]),
1527
+ present=signal_dict["present"],
1528
+ confidence=signal_dict["confidence"],
1529
+ member_id=signal_dict.get("member_id"),
1530
+ timestamp=signal_dict.get("timestamp", time.time()),
1531
+ )
1532
+ self.occupancy._signals.append(signal)
1533
+ except Exception as e:
1534
+ log.warning("Could not restore presence signal: %s", e)
1535
+
1536
+ log.info(
1537
+ "Hub restored: %d device(s), %d audit entries",
1538
+ len(self.registry.all()),
1539
+ len(self.audit_log.entries()),
1540
+ )
1541
+
1542
+ def set_family_profile(self, profile: FamilyProfile) -> None:
1543
+ """Load the family profile into the hub and persist it."""
1544
+ self.family_profile = profile
1545
+ self.db.save_family_profile(profile.to_dict())
1546
+ self.audit_log.append({
1547
+ "type": "profile_loaded",
1548
+ "family_name": profile.family_name,
1549
+ "bedtime": f"{profile.bedtime_hour:02d}:{profile.bedtime_minute:02d}",
1550
+ })
1551
+ log.info("Family profile loaded: %s", profile.family_name)
1552
+
1553
+ # ── Occupancy / presence ─────────────────────────────────────────────────
1554
+
1555
+ def update_presence(self, signal: PresenceSignal) -> OccupancyState:
1556
+ """A context provider updates its presence signal."""
1557
+ self.occupancy.update(signal)
1558
+ self.db.save_presence_signal(signal.device_id, {
1559
+ "device_id": signal.device_id,
1560
+ "signal_type": signal.signal_type.value,
1561
+ "present": signal.present,
1562
+ "confidence": signal.confidence,
1563
+ "member_id": signal.member_id,
1564
+ "timestamp": signal.timestamp,
1565
+ })
1566
+ state = self.occupancy.get_occupancy()
1567
+ self.audit_log.append({
1568
+ "type": "presence_updated",
1569
+ "device_id": signal.device_id,
1570
+ "signal_type": signal.signal_type.value,
1571
+ "present": signal.present,
1572
+ "confidence": signal.confidence,
1573
+ "occupied": state.occupied,
1574
+ "occ_confidence": state.confidence,
1575
+ })
1576
+ return state
1577
+
1578
+ def get_occupancy(self) -> OccupancyState:
1579
+ """Current inferred occupancy state."""
1580
+ return self.occupancy.get_occupancy()
1581
+
1582
+ # ── Device management ────────────────────────────────────────────────────
1583
+
1584
+ def register_device(self, manifest: CapabilityManifest) -> None:
1585
+ """
1586
+ Register or update a device in the hub registry.
1587
+
1588
+ On re-registration, classifies the change using firmware + capability diff
1589
+ (per DoSync spec §14) and emits the appropriate audit entry and events:
1590
+
1591
+ - No change: silent reconnect, no extra audit entry
1592
+ - firmware changed only: device_firmware_updated audit entry
1593
+ - caps changed (fw too): device_updated audit entry with diff
1594
+ - caps changed (same fw): device_capability_anomaly + alert_anomaly intent
1595
+ """
1596
+ existing = self.registry.get(manifest.device_id)
1597
+
1598
+ # First-time registration
1599
+ if existing is None:
1600
+ self.registry.register(manifest)
1601
+ self.db.save_device(manifest.device_id, manifest.to_dict())
1602
+ self.audit_log.append({
1603
+ "type": "device_registered",
1604
+ "device_id": manifest.device_id,
1605
+ "device_name": manifest.device_name,
1606
+ })
1607
+ return
1608
+
1609
+ # ── Re-registration: compute diff ────────────────────────────────────
1610
+ fw_changed = existing.firmware != manifest.firmware
1611
+ ec_changed = existing.emergency_capable != manifest.emergency_capable
1612
+ tags_changed = set(existing.tags) != set(manifest.tags)
1613
+ act_changed = (
1614
+ sorted(a.type for a in existing.actuators) !=
1615
+ sorted(a.type for a in manifest.actuators)
1616
+ )
1617
+ caps_changed = ec_changed or tags_changed or act_changed
1618
+
1619
+ # Silent reconnect — nothing changed
1620
+ if not fw_changed and not caps_changed:
1621
+ self.registry.register(manifest)
1622
+ self.db.save_device(manifest.device_id, manifest.to_dict())
1623
+ return
1624
+
1625
+ # Build diff for audit
1626
+ diff = {}
1627
+ if fw_changed:
1628
+ diff["firmware"] = {"from": existing.firmware, "to": manifest.firmware}
1629
+ if ec_changed:
1630
+ diff["emergency_capable"] = {
1631
+ "from": existing.emergency_capable,
1632
+ "to": manifest.emergency_capable,
1633
+ }
1634
+ if tags_changed:
1635
+ diff["tags"] = {
1636
+ "added": list(set(manifest.tags) - set(existing.tags)),
1637
+ "removed": list(set(existing.tags) - set(manifest.tags)),
1638
+ }
1639
+ if act_changed:
1640
+ old_act = set(a.type for a in existing.actuators)
1641
+ new_act = set(a.type for a in manifest.actuators)
1642
+ diff["actuators"] = {
1643
+ "added": list(new_act - old_act),
1644
+ "removed": list(old_act - new_act),
1645
+ }
1646
+
1647
+ # ── Dr. Esteves classification ────────────────────────────────────────
1648
+ # firmware changed + caps changed → expected firmware update
1649
+ # firmware changed + caps stable → minor firmware upgrade
1650
+ # firmware stable + caps changed → ANOMALY — alert
1651
+ if fw_changed and caps_changed:
1652
+ change_type = "firmware_update"
1653
+ elif fw_changed and not caps_changed:
1654
+ change_type = "firmware_upgrade_minor"
1655
+ else: # not fw_changed and caps_changed
1656
+ change_type = "capability_anomaly"
1657
+
1658
+ # Update registry and DB
1659
+ self.registry.register(manifest)
1660
+ self.db.save_device(manifest.device_id, manifest.to_dict())
1661
+
1662
+ # Emit audit entry
1663
+ if change_type == "capability_anomaly":
1664
+ self.audit_log.append({
1665
+ "type": "device_capability_anomaly",
1666
+ "device_id": manifest.device_id,
1667
+ "device_name": manifest.device_name,
1668
+ "diff": diff,
1669
+ "note": "Capabilities changed without firmware version change — may indicate compromise",
1670
+ })
1671
+ # Fire alert_anomaly intent for security-relevant changes
1672
+ import asyncio
1673
+ from dosync.models import Intent, IntentClass, Urgency
1674
+ alert_intent = Intent(
1675
+ intent=IntentClass("alert_anomaly"),
1676
+ urgency=Urgency.ALERT,
1677
+ context={
1678
+ "trigger": "device_capability_anomaly",
1679
+ "device_id": manifest.device_id,
1680
+ "device_name": manifest.device_name,
1681
+ "diff": diff,
1682
+ },
1683
+ source="hub",
1684
+ )
1685
+ # Fire the alert intent. This is a SECURITY path (capabilities changed
1686
+ # without a firmware bump), so "best-effort" must not mean "silent":
1687
+ # registration is never blocked, but a failure to raise the alert is
1688
+ # logged loudly rather than swallowed.
1689
+ #
1690
+ # asyncio.get_event_loop() was deprecated in 3.10 and is scheduled to
1691
+ # raise when no loop is running. Under the old code that would have
1692
+ # made the no-loop branch unreachable and dropped the alert into a
1693
+ # bare `except: pass` — the POL-2 failure mode (a silent except
1694
+ # hiding a broken security path). Both cases are now explicit.
1695
+ try:
1696
+ loop = asyncio.get_running_loop()
1697
+ except RuntimeError:
1698
+ loop = None
1699
+
1700
+ # The alert needs an executor. Until 2026-07-21 this call passed none
1701
+ # and raised TypeError on EVERY anomaly — swallowed whole by a bare
1702
+ # `except Exception: pass`, so this security alert had never once
1703
+ # fired. The anomaly itself was always audited (above); only the
1704
+ # dispatch was dead. Missing executor is now reported, not hidden.
1705
+ if self.default_executor is None:
1706
+ log.error(
1707
+ "Capability-anomaly alert for %s NOT dispatched: no default_executor "
1708
+ "wired on the hub. The anomaly is recorded in the audit chain, but no "
1709
+ "intent was raised.", manifest.device_id)
1710
+ loop = None
1711
+ alert_intent = None
1712
+
1713
+ if alert_intent is not None and loop is not None:
1714
+ # Inside a running loop (normal server path): schedule it without
1715
+ # blocking registration, but attach a callback so a failure in the
1716
+ # detached task is reported instead of vanishing.
1717
+ task = asyncio.ensure_future(
1718
+ self.execute_intent(alert_intent, self.default_executor))
1719
+
1720
+ def _report_alert_outcome(t: "asyncio.Future") -> None:
1721
+ if t.cancelled():
1722
+ log.warning("Capability-anomaly alert for %s was cancelled",
1723
+ manifest.device_id)
1724
+ return
1725
+ exc = t.exception()
1726
+ if exc is not None:
1727
+ log.error("Capability-anomaly alert for %s FAILED to execute: %s",
1728
+ manifest.device_id, exc)
1729
+
1730
+ task.add_done_callback(_report_alert_outcome)
1731
+ elif alert_intent is not None:
1732
+ # No running loop (CLI, migration scripts, sync tests): run it to
1733
+ # completion, as the previous run_until_complete branch did.
1734
+ try:
1735
+ asyncio.run(self.execute_intent(alert_intent, self.default_executor))
1736
+ except Exception as e:
1737
+ log.error("Capability-anomaly alert for %s FAILED to execute: %s",
1738
+ manifest.device_id, e)
1739
+ else:
1740
+ self.audit_log.append({
1741
+ "type": "device_updated" if caps_changed else "device_firmware_updated",
1742
+ "device_id": manifest.device_id,
1743
+ "device_name": manifest.device_name,
1744
+ "change_type": change_type,
1745
+ "diff": diff,
1746
+ })
1747
+
1748
+ def unregister_device(self, device_id: str) -> None:
1749
+ self.registry.unregister(device_id)
1750
+ self.db.delete_device(device_id)
1751
+ self.audit_log.append({"type": "device_unregistered", "device_id": device_id})
1752
+
1753
+ # ── Intent execution ─────────────────────────────────────────────────────
1754
+
1755
+
1756
+ # ── FailurePolicy execution strategies ────────────────────────────────────
1757
+
1758
+ async def _execute_with_policy_cb(self, plan, executor, intent, progress_cb=None):
1759
+ """MCP-V13: wrap the executor so each completed action can be published as
1760
+ partial progress WITHOUT changing any strategy signature. The wrapper
1761
+ fires progress_cb(result) as each action resolves; the strategies below
1762
+ are untouched. progress_cb is best-effort — a failing callback must never
1763
+ affect execution (an observer cannot break the observed)."""
1764
+ if progress_cb is not None:
1765
+ _inner = executor
1766
+ _hub = self
1767
+
1768
+ class _ProgressExecutor:
1769
+ def __getattr__(self, n): return getattr(_inner, n)
1770
+ async def execute(self, action, urgency):
1771
+ r = await _inner.execute(action, urgency)
1772
+ try:
1773
+ progress_cb(r)
1774
+ except Exception as _cb_e:
1775
+ _hub.progress_cb_failures += 1
1776
+ log.warning("progress_cb raised (ignored, count=%d): %s",
1777
+ _hub.progress_cb_failures, _cb_e)
1778
+ return r
1779
+ executor = _ProgressExecutor()
1780
+ return await self._execute_with_policy(plan, executor, intent)
1781
+
1782
+ async def _execute_with_policy(self, plan, executor, intent):
1783
+ """Dispatch to the correct execution strategy based on failure_policy.
1784
+ Emergency intents always force CONTINUE — protocol-level guarantee."""
1785
+ from .models import FailurePolicy, Urgency
1786
+ policy = plan.failure_policy or FailurePolicy.CONTINUE
1787
+ if intent.urgency == Urgency.EMERGENCY:
1788
+ if policy == FailurePolicy.ABORT:
1789
+ log.info("FailurePolicy.ABORT overridden to CONTINUE for emergency '%s'", intent.intent)
1790
+ policy = FailurePolicy.CONTINUE
1791
+ if policy == FailurePolicy.ABORT:
1792
+ r, f, a = await self._execute_abort(plan.actions, executor, intent)
1793
+ return r, f, a, "abort"
1794
+ elif policy == FailurePolicy.RETRY:
1795
+ max_r = plan.max_retries if plan.max_retries else 1
1796
+ if intent.urgency == Urgency.EMERGENCY:
1797
+ max_r = 1
1798
+ r, f, a = await self._execute_retry(plan.actions, executor, intent, max_r)
1799
+ return r, f, a, "retry"
1800
+ else:
1801
+ r, f, a = await self._execute_parallel(plan.actions, executor, intent)
1802
+ return r, f, a, "continue"
1803
+
1804
+ async def _execute_parallel(self, actions, executor, intent):
1805
+ """CONTINUE: execute all actions in parallel, failures never stop execution."""
1806
+ import os as _os
1807
+ from .models import ActionResult
1808
+ _t = float(_os.environ.get("DOSYNC_INTENT_TIMEOUT",
1809
+ "5.0" if intent.urgency.value == "emergency" else "10.0"))
1810
+ tasks = {asyncio.ensure_future(executor.execute(a, intent.urgency)): a for a in actions}
1811
+ results = []
1812
+ if tasks:
1813
+ done, pending = await asyncio.wait(tasks.keys(), timeout=_t)
1814
+ for fut in done:
1815
+ results.append(fut.result())
1816
+ for fut in pending:
1817
+ action = tasks[fut]
1818
+ log.warning("Timeout: %s/%s after %.1fs", action.device_id, action.action, _t)
1819
+ self.health.mark_unreachable(action.device_id)
1820
+ if hasattr(self.resolver, "mark_unreachable"):
1821
+ self.resolver.mark_unreachable(action.device_id)
1822
+ results.append(ActionResult(device_id=action.device_id, action=action.action,
1823
+ success=False, error=f"timeout after {_t}s"))
1824
+ fut.cancel()
1825
+ return results, [r.device_id for r in results if not r.success], []
1826
+
1827
+ async def _execute_abort(self, actions, executor, intent):
1828
+ """ABORT: execute in batches by relevance_score. Cancel remaining if any batch fails."""
1829
+ import os as _os
1830
+ from .models import ActionResult
1831
+ _t = float(_os.environ.get("DOSYNC_INTENT_TIMEOUT",
1832
+ "5.0" if intent.urgency.value == "emergency" else "10.0"))
1833
+ sorted_actions = sorted(actions, key=lambda a: a.relevance_score, reverse=True)
1834
+ batches = [sorted_actions[i:i+5] for i in range(0, len(sorted_actions), 5)]
1835
+ all_results, aborted = [], []
1836
+ abort_triggered = False
1837
+ for idx, batch in enumerate(batches):
1838
+ if abort_triggered:
1839
+ for a in batch:
1840
+ aborted.append(a.device_id)
1841
+ all_results.append(ActionResult(device_id=a.device_id, action=a.action,
1842
+ success=False, error="aborted — prior batch failed", aborted=True))
1843
+ continue
1844
+ tasks = {asyncio.ensure_future(executor.execute(a, intent.urgency)): a for a in batch}
1845
+ done, pending = await asyncio.wait(tasks.keys(), timeout=_t)
1846
+ batch_results = [fut.result() for fut in done]
1847
+ for fut in pending:
1848
+ a = tasks[fut]
1849
+ self.health.mark_unreachable(a.device_id)
1850
+ if hasattr(self.resolver, "mark_unreachable"):
1851
+ self.resolver.mark_unreachable(a.device_id)
1852
+ batch_results.append(ActionResult(device_id=a.device_id, action=a.action,
1853
+ success=False, error=f"timeout after {_t}s"))
1854
+ fut.cancel()
1855
+ all_results.extend(batch_results)
1856
+ if any(not r.success for r in batch_results):
1857
+ log.warning("ABORT triggered after batch %d/%d — failures: %s",
1858
+ idx+1, len(batches), [r.device_id for r in batch_results if not r.success])
1859
+ abort_triggered = True
1860
+ failed = [r.device_id for r in all_results if not r.success and not r.aborted]
1861
+ return all_results, failed, aborted
1862
+
1863
+ async def _execute_retry(self, actions, executor, intent, max_retries):
1864
+ """RETRY: retry each failed action up to max_retries with exponential backoff."""
1865
+ import os as _os
1866
+ from .models import ActionResult
1867
+ _t = float(_os.environ.get("DOSYNC_INTENT_TIMEOUT",
1868
+ "5.0" if intent.urgency.value == "emergency" else "10.0"))
1869
+
1870
+ async def _with_retry(action):
1871
+ last = None
1872
+ for attempt in range(max_retries + 1):
1873
+ if attempt > 0:
1874
+ backoff = 0.5 * (2 ** (attempt - 1))
1875
+ log.info("RETRY %d/%d for %s (backoff %.1fs)", attempt, max_retries,
1876
+ action.device_id, backoff)
1877
+ await asyncio.sleep(backoff)
1878
+ try:
1879
+ r = await asyncio.wait_for(executor.execute(action, intent.urgency), timeout=_t)
1880
+ r.retries = attempt
1881
+ if r.success:
1882
+ return r
1883
+ last = r
1884
+ except asyncio.TimeoutError:
1885
+ self.health.mark_unreachable(action.device_id)
1886
+ if hasattr(self.resolver, "mark_unreachable"):
1887
+ self.resolver.mark_unreachable(action.device_id)
1888
+ last = ActionResult(device_id=action.device_id, action=action.action,
1889
+ success=False, error=f"timeout (attempt {attempt+1}/{max_retries+1})",
1890
+ retries=attempt)
1891
+ log.warning("RETRY exhausted for %s after %d attempt(s)", action.device_id, max_retries+1)
1892
+ return last or ActionResult(device_id=action.device_id, action=action.action,
1893
+ success=False, error=f"exhausted {max_retries} retries", retries=max_retries)
1894
+
1895
+ results = list(await asyncio.gather(*[_with_retry(a) for a in actions]))
1896
+ return results, [r.device_id for r in results if not r.success], []
1897
+
1898
+ def _validate_plan_params(self, plan, intent):
1899
+ """Validate each action's params against its actuator's JSON Schema.
1900
+
1901
+ Returns (filtered_plan, rejected). Valid actions stay in the plan; each
1902
+ invalid action is dropped and recorded — both in the returned `rejected`
1903
+ list and in the audit log — so nothing is silently discarded.
1904
+
1905
+ Rejection here means "the mind asked for something the actuator declared
1906
+ it does not accept" — distinct from a device that fails to respond at
1907
+ execution time. The audit entry type makes that distinction explicit.
1908
+ """
1909
+ from .models import ActionPlan as _AP
1910
+ from .validation import validate_params
1911
+
1912
+ valid, rejected = [], []
1913
+ for action in plan.actions:
1914
+ device = self.registry.get(action.device_id)
1915
+ schema = None
1916
+ if device:
1917
+ for act in device.actuators:
1918
+ if act.type == action.action:
1919
+ schema = getattr(act, "params_schema", None)
1920
+ break
1921
+ # No device or no schema for this action → nothing to validate against.
1922
+ if not schema:
1923
+ valid.append(action)
1924
+ continue
1925
+
1926
+ ok, err = validate_params(schema, action.params or {})
1927
+ if ok:
1928
+ valid.append(action)
1929
+ else:
1930
+ rejected.append((action, err))
1931
+ log.warning(
1932
+ "Action rejected by param validation: %s.%s — %s",
1933
+ action.device_id, action.action, err,
1934
+ )
1935
+ self.audit_log.append({
1936
+ "type": "action_rejected_invalid_params",
1937
+ "intent_id": intent.intent_id,
1938
+ "intent": intent.intent.value,
1939
+ "device_id": action.device_id,
1940
+ "action": action.action,
1941
+ "params": action.params,
1942
+ "reason": err,
1943
+ "source": getattr(intent, "source", "api"),
1944
+ })
1945
+
1946
+ filtered = _AP(intent_id=plan.intent_id, actions=valid, urgency=plan.urgency,
1947
+ failure_policy=getattr(plan, "failure_policy", None),
1948
+ max_retries=getattr(plan, "max_retries", 1))
1949
+ return filtered, rejected
1950
+
1951
+ # ── Long-running operations: split + write-ahead (execution_model) ────────
1952
+ # This is a SELF-CONTAINED sub-protocol layered on top of intent execution.
1953
+ # The instant path (every existing action) is untouched: these helpers only
1954
+ # ever handle actions whose actuator declares execution_model == "long_running".
1955
+ # An implementer in another language can read this block as one unit.
1956
+
1957
+ def _action_execution_model(self, action: "DeviceAction") -> tuple[str, bool]:
1958
+ """Return (execution_model, emits_telemetry) for an action by looking up
1959
+ its actuator in the device manifest. Defaults to ("instant", False) when the
1960
+ device/actuator is unknown — an unknown action is treated as instant, never
1961
+ long-running, so a missing manifest can never strand an operation."""
1962
+ manifest = self.registry.get(action.device_id)
1963
+ if not manifest:
1964
+ return ("instant", False)
1965
+ for act in manifest.actuators:
1966
+ if act.type == action.action:
1967
+ return (getattr(act, "execution_model", "instant"),
1968
+ getattr(act, "emits_telemetry", False))
1969
+ return ("instant", False)
1970
+
1971
+ def _split_plan_by_execution_model(self, plan):
1972
+ """Split a plan's actions into (instant_actions, long_running_actions).
1973
+ Each action goes to exactly one list — no action is ever in both."""
1974
+ instant, long_running = [], []
1975
+ for action in plan.actions:
1976
+ model, _ = self._action_execution_model(action)
1977
+ (long_running if model == "long_running" else instant).append(action)
1978
+ return instant, long_running
1979
+
1980
+ async def _start_long_running_actions(self, long_running_actions, executor, intent):
1981
+ """For each long-running action: WRITE-AHEAD (create + persist the operation
1982
+ in `pending` BEFORE dispatching), then dispatch, then transition by the
1983
+ dispatch result. Returns a list of {operation_id, device_id, state} for the
1984
+ IntentResult. Never blocks waiting for the action to finish — it only starts.
1985
+
1986
+ Panel rules honored here:
1987
+ - write-ahead: persist `pending` before dispatch, so a crash mid-dispatch
1988
+ never leaves a running device with no operation record.
1989
+ - silence != success: a successful DISPATCH means the device ACCEPTED the
1990
+ command, not that it finished. For a telemetry device the operation waits
1991
+ in `in_progress` for telemetry to confirm/advance; for a core device
1992
+ (no telemetry) the successful dispatch is the only signal there will be,
1993
+ so it goes to `in_progress` and a later positive signal completes it.
1994
+ - graceful degradation: an adapter that doesn't cooperate just returns a
1995
+ normal ActionResult; the operation is resolved from it. No adapter is
1996
+ required to understand operations.
1997
+ """
1998
+ from .operations import Operation, OperationState
1999
+
2000
+ started = []
2001
+ for action in long_running_actions:
2002
+ _, emits_telemetry = self._action_execution_model(action)
2003
+ op = Operation(
2004
+ device_id=action.device_id,
2005
+ action=action.action,
2006
+ telemetry_capable=emits_telemetry,
2007
+ )
2008
+ # WRITE-AHEAD: persist in `pending` before we touch the device.
2009
+ self.db.save_operation(op.to_dict(), terminal=op.is_terminal)
2010
+ self.audit_log.append({
2011
+ "type": "operation_created",
2012
+ "operation_id": op.operation_id,
2013
+ "intent_id": intent.intent_id,
2014
+ "device_id": action.device_id,
2015
+ "action": action.action,
2016
+ "state": op.state.value,
2017
+ })
2018
+
2019
+ # Dispatch (start the action). We do NOT wait for it to finish.
2020
+ try:
2021
+ result = await executor.execute(action, intent.urgency)
2022
+ dispatch_ok = bool(getattr(result, "success", False))
2023
+ err = getattr(result, "error", None)
2024
+ except Exception as e: # an adapter blowing up must not strand the op
2025
+ dispatch_ok = False
2026
+ err = str(e)
2027
+
2028
+ if dispatch_ok:
2029
+ # Accepted by the device. NOT completed — started.
2030
+ op.transition_to(OperationState.IN_PROGRESS,
2031
+ reason="dispatch accepted by device")
2032
+ else:
2033
+ # The device refused / dispatch failed → the operation never ran.
2034
+ op.transition_to(OperationState.FAILED,
2035
+ reason=f"dispatch failed: {err}" if err else "dispatch failed")
2036
+
2037
+ self.db.save_operation(op.to_dict(), terminal=op.is_terminal)
2038
+ self.audit_log.append({
2039
+ "type": "operation_transition",
2040
+ "operation_id": op.operation_id,
2041
+ "intent_id": intent.intent_id,
2042
+ "from_state": "pending",
2043
+ "to_state": op.state.value,
2044
+ })
2045
+ started.append({
2046
+ "operation_id": op.operation_id,
2047
+ "device_id": action.device_id,
2048
+ "state": op.state.value,
2049
+ })
2050
+ return started
2051
+
2052
+ def apply_telemetry(self, device_id: str, event, reason: str = "",
2053
+ phase: str = None, now: float = None) -> dict:
2054
+ """Apply one telemetry fact to a device's active operation.
2055
+
2056
+ This is the GENERIC bridge between any telemetry-emitting adapter and the
2057
+ operation state machine — the first thing in the hub to actually drive the
2058
+ reconciler. It is deliberately device-agnostic: it speaks only the abstract
2059
+ TelemetryEvent vocabulary, knows nothing of MAVLink, drones, or flight. An
2060
+ oven that one day emits telemetry would call this identically. The adapter's
2061
+ job is to translate its device-native signal into a TelemetryEvent; the
2062
+ hub's job (here) is to find the operation, reconcile, persist, and audit.
2063
+
2064
+ Flow:
2065
+ 1. Find the device's single active (non-terminal) operation.
2066
+ 2. Rehydrate it faithfully from the DB (preserving time_in_state/history).
2067
+ 3. Let the stateless reconciler apply the fact (telemetry wins; illegal
2068
+ or duplicate facts are no-ops, never crashes).
2069
+ 4. Persist the (possibly unchanged) operation and audit the outcome.
2070
+
2071
+ Returns a small dict describing what happened — changed/no-op, the
2072
+ from/to states, and a note — so the caller (and tests) can see the result
2073
+ without re-reading the DB. Returns {"matched": False} when the device has
2074
+ no active operation (a stray telemetry packet for an idle device is not an
2075
+ error — it is simply ignored).
2076
+
2077
+ Safety note: a telemetry fact is the ONLY thing that advances an operation
2078
+ toward completion. Silence never does. A disconnection is not a fact and
2079
+ must not be passed here as one — it does not advance anything.
2080
+ """
2081
+ from .operations import Operation
2082
+ from .reconciler import OperationReconciler, TelemetryEvent
2083
+
2084
+ if isinstance(event, str):
2085
+ event = TelemetryEvent(event)
2086
+
2087
+ # 1. Find the device's active operation. There should be at most one
2088
+ # non-terminal operation per device at a time; if several exist (a
2089
+ # pathological state), reconcile the most recent, which get_active_
2090
+ # operations returns first (ORDER BY created_at DESC).
2091
+ active = [o for o in self.db.get_active_operations()
2092
+ if o.get("device_id") == device_id]
2093
+ if not active:
2094
+ log.debug("apply_telemetry: no active operation for %s (event '%s' ignored)",
2095
+ device_id, event.value)
2096
+ return {"matched": False, "device_id": device_id, "event": event.value}
2097
+
2098
+ op_dict = active[0]
2099
+ op = Operation.from_dict(op_dict)
2100
+
2101
+ # Carry an updated sub-phase if the adapter reported one (e.g. "arming").
2102
+ # The protocol never interprets this string; it is domain detail.
2103
+ if phase is not None:
2104
+ op.phase = phase
2105
+
2106
+ # 2 + 3. Reconcile — pure logic, decides the state change (or no-op).
2107
+ reconciler = OperationReconciler()
2108
+ result = reconciler.reconcile(op, event, reason=reason, now=now)
2109
+
2110
+ # 4. Persist (even a no-op may have updated `phase`) and audit.
2111
+ self.db.save_operation(op.to_dict(), terminal=op.is_terminal)
2112
+ self.audit_log.append({
2113
+ "type": "operation_telemetry",
2114
+ "operation_id": op.operation_id,
2115
+ "device_id": device_id,
2116
+ "event": event.value,
2117
+ "changed": result.changed,
2118
+ "from_state": result.from_state.value,
2119
+ "to_state": result.to_state.value,
2120
+ "note": result.note,
2121
+ })
2122
+
2123
+ return {
2124
+ "matched": True,
2125
+ "operation_id": op.operation_id,
2126
+ "device_id": device_id,
2127
+ "event": event.value,
2128
+ "changed": result.changed,
2129
+ "from_state": result.from_state.value,
2130
+ "to_state": result.to_state.value,
2131
+ "note": result.note,
2132
+ }
2133
+
2134
+ # ── Composite intents (Level 2: the nervous system) ───────────────────────
2135
+ # A composition intent (e.g. inspect_area) does not resolve to a flat parallel
2136
+ # ActionPlan. It composes an ORDERED SEQUENCE of atomic operations that the
2137
+ # OperationSupervisor drives in a closed loop — dispatch a step, wait for its
2138
+ # confirmed arrival via reconciled telemetry, then the next, reacting to guards
2139
+ # each tick. This is the brain coordinating the body, not fire-and-forget.
2140
+ #
2141
+ # The pieces (all built and tested independently):
2142
+ # RouteComposer -> the ordered steps (geometry)
2143
+ # CompositeOperation -> the structure that holds them
2144
+ # OperationSupervisor -> the closed-loop driver
2145
+ # OperationGuards -> real-time in-flight monitoring
2146
+ # This method wires them to the live hub (PolicyEngine + executor + telemetry).
2147
+
2148
+ async def _dispatch_composite_step(self, step, executor, intent):
2149
+ """Dispatch ONE composite step as a long-running atomic operation, AFTER the
2150
+ PolicyEngine admits it. Returns the operation_id the supervisor will watch.
2151
+
2152
+ CRITICAL (panel): the composition path does NOT bypass admission security.
2153
+ Every step is evaluated by the PolicyEngine first — the admission geofence,
2154
+ rate limits, manual-control lockout, everything — exactly as a normal intent
2155
+ is. A blocked step raises, the supervisor sees the failed dispatch and aborts.
2156
+
2157
+ Reuses the write-ahead pattern of _start_long_running_actions: persist the
2158
+ operation in `pending` BEFORE touching the device, dispatch, transition by the
2159
+ result. The operation then advances to `completed` later, when telemetry
2160
+ confirms arrival (apply_telemetry) — which is what the supervisor polls for.
2161
+ """
2162
+ from .operations import Operation, OperationState
2163
+ from .models import DeviceAction, ActionPlan as _AP
2164
+
2165
+ action = DeviceAction(
2166
+ device_id=step.device_id, action=step.action, params=dict(step.params or {}))
2167
+
2168
+ # ── PolicyEngine admission for THIS step ──────────────────────────────
2169
+ if self.policy_engine:
2170
+ from .policies import PolicyDecision
2171
+ single_plan = _AP(intent_id=intent.intent_id, actions=[action],
2172
+ urgency=intent.urgency)
2173
+ result = self.policy_engine.evaluate(intent, single_plan)
2174
+ if result.decision == PolicyDecision.BLOCK:
2175
+ self.audit_log.append({
2176
+ "type": "composite_step_blocked",
2177
+ "intent_id": intent.intent_id,
2178
+ "device_id": step.device_id,
2179
+ "action": step.action,
2180
+ "policy": result.policy_name,
2181
+ "reason": result.reason,
2182
+ })
2183
+ raise PermissionError(
2184
+ f"step '{step.action}' blocked by policy '{result.policy_name}': "
2185
+ f"{result.reason}")
2186
+ if result.decision == PolicyDecision.MODIFY and result.modified_actions:
2187
+ action = result.modified_actions[0]
2188
+
2189
+ # ── Write-ahead: persist `pending` before dispatch ───────────────────
2190
+ _, emits_telemetry = self._action_execution_model(action)
2191
+ op = Operation(device_id=action.device_id, action=action.action,
2192
+ telemetry_capable=emits_telemetry)
2193
+ self.db.save_operation(op.to_dict(), terminal=op.is_terminal)
2194
+ self.audit_log.append({
2195
+ "type": "operation_created", "operation_id": op.operation_id,
2196
+ "intent_id": intent.intent_id, "device_id": action.device_id,
2197
+ "action": action.action, "state": op.state.value,
2198
+ "composite": True,
2199
+ })
2200
+
2201
+ # ── Dispatch (start the step). Do NOT wait for it to finish here ──────
2202
+ try:
2203
+ res = await executor.execute(action, intent.urgency)
2204
+ dispatch_ok = bool(getattr(res, "success", False))
2205
+ err = getattr(res, "error", None)
2206
+ except Exception as e:
2207
+ dispatch_ok, err = False, str(e)
2208
+
2209
+ if dispatch_ok:
2210
+ op.transition_to(OperationState.IN_PROGRESS, reason="dispatch accepted")
2211
+ else:
2212
+ op.transition_to(OperationState.FAILED,
2213
+ reason=f"dispatch failed: {err}" if err else "dispatch failed")
2214
+ self.db.save_operation(op.to_dict(), terminal=op.is_terminal)
2215
+ self.audit_log.append({
2216
+ "type": "operation_transition", "operation_id": op.operation_id,
2217
+ "intent_id": intent.intent_id, "from_state": "pending",
2218
+ "to_state": op.state.value, "composite": True,
2219
+ })
2220
+ return op.operation_id
2221
+
2222
+ def _read_operation_state(self, operation_id):
2223
+ """Read the reconciled atomic state of an operation (the state telemetry
2224
+ keeps current). The supervisor polls THIS — never the raw telemetry queue."""
2225
+ row = self.db.get_operation(operation_id)
2226
+ return row.get("state") if row else None
2227
+
2228
+ def _build_guard_context_provider(self, device_id):
2229
+ """Build the GuardContext provider the supervisor calls each tick.
2230
+
2231
+ HONEST SCOPE (panel): it fills what the CURRENT data allows and leaves the
2232
+ rest None. The guards abstain on None (verified), so a guard whose signal is
2233
+ not yet flowing simply does not fire — no dead code, no false promise.
2234
+
2235
+ Active with real data TODAY:
2236
+ - manual_control_active: from the device's operation reaching `interrupted`
2237
+ (a human took control — apply_telemetry sets this from MANUAL_CONTROL_TAKEN)
2238
+ - seconds_in_step: from the active operation's time_in_state
2239
+
2240
+ Waiting on continuous position/battery telemetry (guards already built+tested,
2241
+ activate automatically once the provider starts filling these):
2242
+ - lat/lon/alt -> GeofenceGuard in flight
2243
+ - battery_percent -> BatteryGuard
2244
+ A future step publishes a per-device telemetry snapshot; until then these stay
2245
+ None and their guards correctly abstain.
2246
+ """
2247
+ from .operation_guards import GuardContext
2248
+
2249
+ def provider(comp):
2250
+ manual = False
2251
+ seconds_in_step = None
2252
+ try:
2253
+ for op in self.db.get_active_operations():
2254
+ if op.get("device_id") != device_id:
2255
+ continue
2256
+ if op.get("state") == "interrupted":
2257
+ manual = True
2258
+ entered = op.get("state_entered_at")
2259
+ if entered is not None:
2260
+ import time as _t
2261
+ seconds_in_step = _t.time() - entered
2262
+ except Exception:
2263
+ pass # never let context-building crash the supervisor
2264
+ return GuardContext(
2265
+ device_id=device_id,
2266
+ manual_control_active=manual,
2267
+ seconds_in_step=seconds_in_step,
2268
+ # lat/lon/alt/battery/seconds_since_telemetry: None until a
2269
+ # continuous telemetry snapshot feeds them (guards abstain on None).
2270
+ )
2271
+ return provider
2272
+
2273
+ async def execute_composite_intent(self, intent, executor, context, guard_set=None,
2274
+ config=None):
2275
+ """Execute a composition intent (e.g. inspect_area) as a supervised, closed-
2276
+ loop sequence. Composes the route, builds the CompositeOperation, wires the
2277
+ supervisor to the live hub (PolicyEngine-gated dispatch, reconciled-state
2278
+ polling, guard provider), and runs it.
2279
+
2280
+ Args:
2281
+ intent: the composition Intent (its .context carries center/radius/etc.)
2282
+ executor: the device executor (dispatches atomic steps)
2283
+ context: the resolution context for the composer (center, radius_m, ...)
2284
+ guard_set: an optional OperationGuards.GuardSet; if None, no guards beyond
2285
+ the supervisor's own stall backstop.
2286
+ config: optional SupervisorConfig (poll cadence, step timeout).
2287
+
2288
+ Returns the terminal CompositeState.
2289
+ """
2290
+ from .route_composer import RouteComposer
2291
+ from .composite_operations import CompositeOperation
2292
+ from .operation_supervisor import OperationSupervisor
2293
+
2294
+ device_id = context.get("device_id")
2295
+ if not device_id:
2296
+ raise ValueError("execute_composite_intent requires context['device_id']")
2297
+
2298
+ # 1. Compose the ordered steps (geometry) — the cerebellum's translation.
2299
+ steps = RouteComposer().compose_inspect_area(device_id, context)
2300
+
2301
+ # 2. The structure that holds and tracks them.
2302
+ comp = CompositeOperation(device_id=device_id, intent=intent.intent.value,
2303
+ steps=steps, context=context)
2304
+ self.audit_log.append({
2305
+ "type": "composite_started", "composite_id": comp.composite_id,
2306
+ "intent_id": intent.intent_id, "intent": intent.intent.value,
2307
+ "device_id": device_id, "steps": len(steps),
2308
+ })
2309
+
2310
+ # 3. Wire the supervisor to the live hub.
2311
+ async def dispatch(step):
2312
+ return await self._dispatch_composite_step(step, executor, intent)
2313
+
2314
+ guard_fn = None
2315
+ if guard_set is not None:
2316
+ guard_fn = guard_set.make_guard_fn(
2317
+ self._build_guard_context_provider(device_id))
2318
+
2319
+ supervisor = OperationSupervisor(
2320
+ dispatch=dispatch,
2321
+ read_state=self._read_operation_state,
2322
+ guard=guard_fn,
2323
+ config=config,
2324
+ )
2325
+
2326
+ # 4. Run the closed loop to a terminal mission state.
2327
+ final = await supervisor.run(comp)
2328
+
2329
+ self.audit_log.append({
2330
+ "type": "composite_finished", "composite_id": comp.composite_id,
2331
+ "intent_id": intent.intent_id, "final_state": final.value,
2332
+ "steps_completed": sum(1 for s in comp.steps if s.done),
2333
+ "steps_total": len(comp.steps),
2334
+ })
2335
+ return final
2336
+
2337
+ async def _route_composite_intent(self, intent, executor, composition_kind):
2338
+ """Route a composition intent to the right composer and run it as a closed-
2339
+ loop supervised sequence, wrapping the terminal CompositeState in the
2340
+ IntentResult the caller expects.
2341
+
2342
+ The selection is a simple `if`, not a generic registry (panel: no premature
2343
+ abstraction with one composer). An UNKNOWN kind fails EXPLICITLY — a declared
2344
+ composition the hub cannot compose is a configuration error that must shout,
2345
+ never a silent fall-through to the flat path.
2346
+
2347
+ The composition context (center, radius_m, device_id, ...) travels in
2348
+ intent.context — the same place every intent carries its context.
2349
+ """
2350
+ if composition_kind != "perimeter":
2351
+ # Explicit failure — never silently degrade to the flat path.
2352
+ log.error("Intent '%s' declares composition_kind='%s' but no composer "
2353
+ "handles it.", intent.intent.value, composition_kind)
2354
+ self.audit_log.append({
2355
+ "type": "composite_unknown_kind",
2356
+ "intent_id": intent.intent_id,
2357
+ "intent": intent.intent.value,
2358
+ "composition_kind": composition_kind,
2359
+ })
2360
+ return IntentResult(
2361
+ intent_id=intent.intent_id, success=False, results=[],
2362
+ failed_devices=[], status="failed",
2363
+ )
2364
+
2365
+ from .composite_operations import CompositeState
2366
+ try:
2367
+ final_state = await self.execute_composite_intent(
2368
+ intent, executor, context=intent.context)
2369
+ except ValueError as e:
2370
+ # e.g. missing device_id / center in the context.
2371
+ log.error("Composite intent '%s' rejected: %s", intent.intent.value, e)
2372
+ self.audit_log.append({
2373
+ "type": "composite_rejected",
2374
+ "intent_id": intent.intent_id,
2375
+ "intent": intent.intent.value,
2376
+ "reason": str(e),
2377
+ })
2378
+ return IntentResult(
2379
+ intent_id=intent.intent_id, success=False, results=[],
2380
+ failed_devices=[], status="failed",
2381
+ )
2382
+
2383
+ # Map the terminal CompositeState to the IntentResult contract.
2384
+ success = final_state == CompositeState.COMPLETED
2385
+ return IntentResult(
2386
+ intent_id=intent.intent_id, success=success, results=[],
2387
+ failed_devices=[], status=final_state.value,
2388
+ )
2389
+
2390
+ def _resolve_verify_bindings(self, plan, intent) -> None:
2391
+ """INDEPENDENT-OBSERVATION (panel D1): fill in each action's verify_with
2392
+ from the manifest (the manufacturer's natural pairing) and/or the intent
2393
+ context (a deployment cross-device binding, which overrides).
2394
+
2395
+ Intent context format:
2396
+ context["verify_with"] = {
2397
+ "lock-front": {"sensor_id": "door-sensor:bolt",
2398
+ "expected_reading": "locked", "deadline_s": 5},
2399
+ "lock-front:unlock": {...} # optional per-action override
2400
+ }
2401
+ Keys are matched most-specific-first: "device:action", then "device".
2402
+ Anything malformed is IGNORED with a warning — a bad binding must never
2403
+ break dispatch (verification is an observation, not a gate).
2404
+ """
2405
+ from .models import VerifyBinding
2406
+
2407
+ ctx = (intent.context or {}).get("verify_with") or {}
2408
+ for action in plan.actions:
2409
+ binding = None
2410
+
2411
+ # 1. Manifest: the actuator's own declared pairing, if any.
2412
+ device = self.registry.get(action.device_id)
2413
+ if device:
2414
+ for act in getattr(device, "actuators", []):
2415
+ if act.type == action.action or act.id == action.action:
2416
+ binding = getattr(act, "verify_with", None)
2417
+ break
2418
+
2419
+ # 2. Intent context overrides (most specific key wins).
2420
+ raw = ctx.get(f"{action.device_id}:{action.action}") or ctx.get(action.device_id)
2421
+ if raw is not None:
2422
+ try:
2423
+ binding = raw if isinstance(raw, VerifyBinding) else VerifyBinding(
2424
+ sensor_id=raw["sensor_id"],
2425
+ expected_reading=raw["expected_reading"],
2426
+ deadline_s=float(raw.get("deadline_s", 5.0)))
2427
+ except Exception as e:
2428
+ log.warning("Ignoring malformed verify_with for %s/%s: %s",
2429
+ action.device_id, action.action, e)
2430
+
2431
+ if binding is not None:
2432
+ action.verify_with = binding
2433
+
2434
+ async def execute_intent(
2435
+ self,
2436
+ intent: Intent,
2437
+ executor: "DeviceExecutor",
2438
+ progress_cb=None,
2439
+ ) -> IntentResult:
2440
+ log.info("Executing intent: %s [%s]", intent.intent.value, intent.urgency.value)
2441
+
2442
+ # Wrap the executor once to time every device action regardless of the
2443
+ # execution path (parallel/abort/retry/long-running). A timing wrapper
2444
+ # here means we don't have to instrument each call site — and it never
2445
+ # changes behavior: it awaits the real execute and records the elapsed
2446
+ # time by result. Metrics are optional; a failure to record is swallowed.
2447
+ if _M is not None and not getattr(executor, "_dosync_timed", False):
2448
+ executor = _TimedExecutor(executor, hub=self)
2449
+
2450
+ # ── Composition routing (Level 2) ─────────────────────────────────────
2451
+ # A composition intent (declared with composition_kind, e.g. inspect_area
2452
+ # -> "perimeter") does NOT resolve to a flat parallel plan. It composes an
2453
+ # ordered sequence the OperationSupervisor drives in a closed loop. The
2454
+ # routing decision lives HERE in the hub — not in the REST endpoint — so
2455
+ # every client (REST, MCP, tests) inherits the same behavior.
2456
+ #
2457
+ # An intent WITHOUT composition_kind falls straight through to the normal
2458
+ # flat path below — zero change for every existing intent. An UNKNOWN kind
2459
+ # fails explicitly (never silently falls to the flat path): a declared
2460
+ # composition the hub cannot compose is a configuration error that must shout.
2461
+ intent_class_row = self.db.get_intent_class(intent.intent.value)
2462
+ composition_kind = (intent_class_row or {}).get("composition_kind")
2463
+ if composition_kind:
2464
+ return await self._route_composite_intent(
2465
+ intent, executor, composition_kind)
2466
+
2467
+ _t0 = time.perf_counter()
2468
+ plan = self.resolver.resolve(intent)
2469
+ if _M is not None:
2470
+ _M.intent_resolution_seconds.observe(time.perf_counter() - _t0)
2471
+
2472
+ # ── Parameter validation (protocol v0.3) ──────────────────────────────
2473
+ # Validate each action's params against its actuator's JSON Schema BEFORE
2474
+ # dispatch. An action whose params violate the schema is rejected
2475
+ # individually, recorded in the audit log, and the rest of the plan
2476
+ # continues (partial result) — a single bad parameter never aborts a plan.
2477
+ #
2478
+ # Opt-out by latency: on the EMERGENCY path, validation is skipped so the
2479
+ # response is never delayed. Even when active, rejection-and-continue means
2480
+ # validation cannot tumble an emergency — only the invalid action drops.
2481
+ # Controlled by DOSYNC_VALIDATE_PARAMS (default "true"); emergencies always skip.
2482
+ rejected_actions = []
2483
+ _validate = os.environ.get("DOSYNC_VALIDATE_PARAMS", "true").lower() == "true"
2484
+ if _validate and intent.urgency != Urgency.EMERGENCY:
2485
+ plan, rejected_actions = self._validate_plan_params(plan, intent)
2486
+
2487
+ # Policy Engine evaluation
2488
+ if self.policy_engine:
2489
+ from .policies import PolicyDecision
2490
+ from .models import ActionPlan as _AP
2491
+ policy_result = self.policy_engine.evaluate(intent, plan)
2492
+ if policy_result.decision == PolicyDecision.BLOCK:
2493
+ # Determine if this is an emergency intent blocked by a non-bypassable policy
2494
+ # This is a security-notable event: operator explicitly overrides emergency bypass
2495
+ is_emergency_block = (
2496
+ intent.urgency == Urgency.EMERGENCY
2497
+ and not getattr(
2498
+ next((p for p in self.policy_engine._policies
2499
+ if p.name == policy_result.policy_name), None),
2500
+ "bypass_on_emergency", True
2501
+ )
2502
+ )
2503
+ audit_type = "emergency_intent_blocked_by_policy" if is_emergency_block else "intent_blocked"
2504
+ log.warning(
2505
+ "Intent %s BLOCKED by policy '%s' (urgency=%s, emergency_override=%s): %s",
2506
+ intent.intent.value, policy_result.policy_name,
2507
+ intent.urgency.value, is_emergency_block, policy_result.reason,
2508
+ )
2509
+ self.audit_log.append({
2510
+ "type": audit_type,
2511
+ "intent_id": intent.intent_id,
2512
+ "intent": intent.intent.value,
2513
+ "urgency": intent.urgency.value,
2514
+ "source": getattr(intent, "source", "api"),
2515
+ "policy": policy_result.policy_name,
2516
+ "reason": policy_result.reason,
2517
+ "emergency_override": is_emergency_block,
2518
+ })
2519
+ return IntentResult(intent_id=intent.intent_id, success=False, results=[], failed_devices=[])
2520
+ elif policy_result.decision == PolicyDecision.CONFIRM:
2521
+ log.info("Intent PENDING CONFIRMATION by policy '%s': %s",
2522
+ policy_result.policy_name, policy_result.reason)
2523
+ self.audit_log.append({
2524
+ "type": "intent_pending_confirmation",
2525
+ "intent_id": intent.intent_id,
2526
+ "intent": intent.intent.value,
2527
+ "policy": policy_result.policy_name,
2528
+ "reason": policy_result.reason,
2529
+ })
2530
+ return IntentResult(intent_id=intent.intent_id, success=False, results=[], failed_devices=[])
2531
+ elif policy_result.decision == PolicyDecision.MODIFY:
2532
+ # ── AUDIT-PROVENANCE (2026-07-18, from external review) ──────
2533
+ # Until today a MODIFY left its trace in the runtime log and
2534
+ # NOWHERE in the tamper-evident chain: BLOCK and CONFIRM were
2535
+ # chain-bound, but the most common policy decision — "the plan
2536
+ # ran, minus these devices" — was reconstructible only from a
2537
+ # rotating journal. The chain must bind the DECISION, not just
2538
+ # the commands sent: what was proposed, what was removed, which
2539
+ # policy decided, and the fingerprint of the exact policy file
2540
+ # that was loaded (the file on disk may change; the hash of
2541
+ # what this hub enforced does not).
2542
+ _pre_devices = sorted({a.device_id for a in plan.actions})
2543
+ _post_devices = sorted({a.device_id for a in policy_result.modified_actions})
2544
+ self.audit_log.append({
2545
+ "type": "policy_modified",
2546
+ "intent_id": intent.intent_id,
2547
+ "intent": intent.intent.value,
2548
+ "urgency": intent.urgency.value,
2549
+ "source": getattr(intent, "source", "api"),
2550
+ "policy": policy_result.policy_name,
2551
+ "reason": policy_result.reason,
2552
+ "pre_policy_devices": _pre_devices,
2553
+ "post_policy_devices": _post_devices,
2554
+ "removed_devices": sorted(set(_pre_devices) - set(_post_devices)),
2555
+ "policies_fingerprint": getattr(self.policy_engine, "policies_fingerprint", None),
2556
+ })
2557
+ plan = _AP(intent_id=plan.intent_id, actions=policy_result.modified_actions, urgency=plan.urgency)
2558
+
2559
+ # ── EMERGENCY-UNSAT-ESCALATION (same review) ─────────────────
2560
+ # Stacked absolute exclusions CAN empty an emergency plan. The
2561
+ # wrong fix is rejecting the plan — that would override the
2562
+ # operator's declared judgment, which is precisely what this
2563
+ # layer refuses to do. The failure mode is not obedience, it is
2564
+ # SILENCE: until today this executed zero actions with status
2565
+ # "completed" and nobody was told. Honor the rules; say so
2566
+ # loudly; leave a dedicated chain entry.
2567
+ if (intent.urgency == Urgency.EMERGENCY
2568
+ and _pre_devices and not plan.actions):
2569
+ log.critical(
2570
+ "EMERGENCY intent %s is UNSATISFIABLE: %d device(s) resolved, "
2571
+ "0 remain after policy filtering. Your standing rules made this "
2572
+ "emergency a no-op — review the deployment policy file.",
2573
+ intent.intent.value, len(_pre_devices),
2574
+ )
2575
+ self.audit_log.append({
2576
+ "type": "emergency_unsatisfiable",
2577
+ "intent_id": intent.intent_id,
2578
+ "intent": intent.intent.value,
2579
+ "source": getattr(intent, "source", "api"),
2580
+ "resolved_devices": _pre_devices,
2581
+ "policy": policy_result.policy_name,
2582
+ "policies_fingerprint": getattr(self.policy_engine, "policies_fingerprint", None),
2583
+ })
2584
+
2585
+ # Register active intent for conflict detection
2586
+ from .policies import get_intent_priority
2587
+ intent_value = intent.intent.value
2588
+ self._active_intents[intent_value] = get_intent_priority(intent_value)
2589
+ self._active_intent_devices[intent_value] = {a.device_id for a in plan.actions}
2590
+
2591
+ # ── Split by execution model (execution_model) ─────────────────────────
2592
+ # Long-running actions follow a separate sub-protocol: they are STARTED and
2593
+ # tracked as operations, not awaited to completion. Instant actions take the
2594
+ # existing path completely unchanged. An intent with no long-running actions
2595
+ # behaves exactly as before (started_operations stays empty).
2596
+ from .models import ActionPlan as _APlan
2597
+ _instant_actions, _long_running_actions = self._split_plan_by_execution_model(plan)
2598
+ started_operations = []
2599
+ if _long_running_actions:
2600
+ started_operations = await self._start_long_running_actions(
2601
+ _long_running_actions, executor, intent
2602
+ )
2603
+ # The instant path below operates only on the instant sub-plan.
2604
+ plan = _APlan(intent_id=plan.intent_id, actions=_instant_actions,
2605
+ urgency=plan.urgency,
2606
+ failure_policy=getattr(plan, "failure_policy", None))
2607
+
2608
+ # INDEPENDENT-OBSERVATION: resolve each action's verify_with binding
2609
+ # before dispatch. Manifest declares the manufacturer's natural pairing;
2610
+ # the intent context can add or OVERRIDE a cross-device binding, and wins
2611
+ # (panel decision D1 — the manufacturer cannot know sensors it does not
2612
+ # ship with). Opt-in throughout: an action with no binding from either
2613
+ # source stays verify_with=None and behaves exactly as before.
2614
+ self._resolve_verify_bindings(plan, intent)
2615
+
2616
+ # Execute with the plan's FailurePolicy
2617
+ results, failed, aborted, policy_applied = [], [], [], "continue"
2618
+ try:
2619
+ results, failed, aborted, policy_applied = await self._execute_with_policy_cb(
2620
+ plan, executor, intent, progress_cb=progress_cb
2621
+ )
2622
+ finally:
2623
+ _claim_devices = self._active_intent_devices.get(intent_value, set())
2624
+ self._active_intents.pop(intent_value, None)
2625
+ self._active_intent_devices.pop(intent_value, None)
2626
+ # Release any device claim this intent asserted at the arbiter layer, so
2627
+ # the short grace window starts now (see dosync/device_arbiter.py). The
2628
+ # rank guard ensures a lower-urgency intent completing first cannot start
2629
+ # the grace on a higher-urgency (emergency) claim on a shared device.
2630
+ _release = getattr(executor, "release_claim", None)
2631
+ if _release is not None and _claim_devices:
2632
+ try:
2633
+ _rank = {"info": 0, "warning": 1, "alert": 2, "emergency": 3}.get(
2634
+ getattr(intent.urgency, "value", str(intent.urgency)), 0)
2635
+ _release(_claim_devices, _rank)
2636
+ except Exception:
2637
+ pass
2638
+
2639
+ # INDEPENDENT-OBSERVATION (panel design 2026-07-21): a verification result
2640
+ # that CONTRADICTS or is UNVERIFIABLE is a first-class audit event, with
2641
+ # expected/observed/sensor/independence. The protocol REPORTS honestly and
2642
+ # does NOT act (no auto-retry, no auto-escalation — panel decision D2):
2643
+ # the response is deployment policy, never protocol-automatic. `success`
2644
+ # is untouched — the device accepted the command; verification answers the
2645
+ # separate question of whether the effect was independently observed.
2646
+ from .models import VerificationStatus as _VS
2647
+ for _r in results:
2648
+ _v = getattr(_r, "verification", None)
2649
+ if _v is None or _v.status in (_VS.VERIFIED, _VS.UNVERIFIED):
2650
+ continue
2651
+ _ev = ("action_contradicted" if _v.status == _VS.CONTRADICTED
2652
+ else "action_unverifiable")
2653
+ self.audit_log.append({
2654
+ "type": _ev,
2655
+ "intent_id": intent.intent_id,
2656
+ "device_id": _r.device_id,
2657
+ "action": _r.action,
2658
+ "sensor_id": _v.sensor_id,
2659
+ "expected": _v.expected,
2660
+ "observed": _v.observed,
2661
+ "independence": _v.independence,
2662
+ })
2663
+ if _v.status == _VS.CONTRADICTED:
2664
+ log.warning("Action %s on %s reported success but sensor %s CONTRADICTS: "
2665
+ "expected=%r observed=%r (%s) — reporting, not acting (policy decides)",
2666
+ _r.action, _r.device_id, _v.sensor_id, _v.expected,
2667
+ _v.observed, _v.independence)
2668
+
2669
+ # Rejected-by-validation actions count against full success: the plan did
2670
+ # not do everything the mind asked. Per the panel, this resolves to
2671
+ # `partial` even if every dispatched action succeeded — and is recorded
2672
+ # distinctly from device failures (see audit type above).
2673
+ has_rejected = len(rejected_actions) > 0
2674
+ has_operations = len(started_operations) > 0
2675
+ success = len(failed) == 0 and len(aborted) == 0 and not has_rejected
2676
+ if not results and not has_rejected and has_operations:
2677
+ # The intent only started long-running operations (no instant actions).
2678
+ # Not failed — accepted and running. `accepted` is the honest status:
2679
+ # nothing is done yet, but operations are underway.
2680
+ status = "accepted"
2681
+ elif not results and not has_rejected:
2682
+ status = "failed"
2683
+ elif aborted:
2684
+ status = "partial_abort"
2685
+ elif not results and has_rejected:
2686
+ # Every action was rejected by validation; nothing executed.
2687
+ status = "rejected_invalid_params"
2688
+ elif failed and len(failed) < len(results):
2689
+ status = "partial"
2690
+ elif failed:
2691
+ status = "failed"
2692
+ elif has_rejected:
2693
+ # Some actions executed, some rejected by validation → partial.
2694
+ status = "partial"
2695
+ elif any(getattr(r, "retries", 0) > 0 and not r.success for r in results):
2696
+ status = "retry_exhausted"
2697
+ elif has_operations:
2698
+ # Instant actions all succeeded AND long-running operations started:
2699
+ # the instant part is done but the intent as a whole is still running.
2700
+ status = "accepted"
2701
+ else:
2702
+ status = "success"
2703
+
2704
+ intent_result = IntentResult(
2705
+ intent_id=intent.intent_id,
2706
+ success=success,
2707
+ results=results,
2708
+ failed_devices=failed,
2709
+ aborted_devices=aborted,
2710
+ failure_policy_applied=policy_applied,
2711
+ status=status,
2712
+ rejected_actions=[
2713
+ {"device_id": a.device_id, "action": a.action, "reason": err}
2714
+ for a, err in rejected_actions
2715
+ ],
2716
+ operations=started_operations,
2717
+ )
2718
+ # Audit log
2719
+ self.audit_log.append({
2720
+ "type": "intent_executed",
2721
+ "intent_id": intent.intent_id,
2722
+ "intent": intent.intent.value,
2723
+ "urgency": intent.urgency.value,
2724
+ "source": getattr(intent, "source", "api"),
2725
+ "actions": len(plan.actions),
2726
+ "failed": failed,
2727
+ "aborted": aborted,
2728
+ "failure_policy": policy_applied,
2729
+ "status": status,
2730
+ "success": success,
2731
+ })
2732
+
2733
+ return intent_result
2734
+
2735
+ # ── Event handling (device → AI) ─────────────────────────────────────────
2736
+
2737
+ def on_event(self, handler: Callable[[DeviceEvent], None]) -> None:
2738
+ self._event_handlers.append(handler)
2739
+
2740
+ async def receive_event(self, event: DeviceEvent) -> None:
2741
+ log.info("Event received: %s from %s [%s]",
2742
+ event.event_id, event.device_id, event.severity.value)
2743
+
2744
+ self.audit_log.append({
2745
+ "type": "device_event",
2746
+ "device_id": event.device_id,
2747
+ "event_id": event.event_id,
2748
+ "severity": event.severity.value,
2749
+ "data": event.data,
2750
+ })
2751
+
2752
+ for handler in self._event_handlers:
2753
+ if asyncio.iscoroutinefunction(handler):
2754
+ await handler(event)
2755
+ else:
2756
+ handler(event)
2757
+
2758
+ # ── Phased intent execution ──────────────────────────────────────────────
2759
+
2760
+ async def execute_phased(
2761
+ self,
2762
+ plan: PhasedActionPlan,
2763
+ executor: "DeviceExecutor",
2764
+ ) -> list[IntentResult]:
2765
+ """
2766
+ Ejecuta un PhasedActionPlan: cada fase en paralelo,
2767
+ las fases en secuencia con delay entre ellas.
2768
+ Ideal para emergencias donde el orden importa.
2769
+ """
2770
+ all_results = []
2771
+
2772
+ for i, phase in enumerate(plan.phases):
2773
+ log.info(
2774
+ "Executing phase %d/%d: '%s' (%d actions)",
2775
+ i + 1, len(plan.phases), phase.name, len(phase.actions),
2776
+ )
2777
+
2778
+ from .models import ActionPlan as _PhAP, DeviceAction as _PhDA
2779
+ from .models import Intent as _PhI, IntentClass as _PhIC
2780
+ phase_plan = _PhAP(
2781
+ intent_id=f"{plan.intent_id}-phase{i+1}",
2782
+ actions=[_PhDA(device_id=a.device_id, action=a.action, params=a.params)
2783
+ for a in phase.actions],
2784
+ urgency=plan.urgency,
2785
+ failure_policy=getattr(plan, "failure_policy", None),
2786
+ max_retries=getattr(plan, "max_retries", 1),
2787
+ )
2788
+ phase_intent = _PhI(intent=_PhIC("report_status"), urgency=plan.urgency, context={})
2789
+ p_res, p_fail, p_abort, p_pol = await self._execute_with_policy(
2790
+ phase_plan, executor, phase_intent
2791
+ )
2792
+ p_ok = len(p_fail) == 0 and len(p_abort) == 0
2793
+ p_st = "success" if p_ok else ("partial_abort" if p_abort else "partial")
2794
+ phase_result = IntentResult(
2795
+ intent_id=f"{plan.intent_id}-phase{i+1}",
2796
+ success=p_ok,
2797
+ results=p_res,
2798
+ failed_devices=p_fail,
2799
+ aborted_devices=p_abort,
2800
+ failure_policy_applied=p_pol,
2801
+ status=p_st,
2802
+ )
2803
+ all_results.append(phase_result)
2804
+ self.audit_log.append({
2805
+ "type": "phase_executed",
2806
+ "intent_id": plan.intent_id,
2807
+ "phase": phase.name,
2808
+ "phase_num": i + 1,
2809
+ "actions": len(phase.actions),
2810
+ "failed": p_fail,
2811
+ "aborted": p_abort,
2812
+ "failure_policy": p_pol,
2813
+ "success": p_ok,
2814
+ })
2815
+ # ABORT propagation: cancel remaining phases if this one failed
2816
+ if not p_ok and getattr(plan, "failure_policy", None) and \
2817
+ getattr(plan.failure_policy, "value", "") == "abort":
2818
+ log.warning("ABORT: phase %d/%d failed — cancelling %d remaining",
2819
+ i+1, len(plan.phases), len(plan.phases)-i-1)
2820
+ for rp in plan.phases[i+1:]:
2821
+ all_results.append(IntentResult(
2822
+ intent_id=f"{plan.intent_id}-phase{plan.phases.index(rp)+1}",
2823
+ success=False, results=[], failed_devices=[],
2824
+ aborted_devices=[a.device_id for a in rp.actions],
2825
+ failure_policy_applied="abort", status="partial_abort",
2826
+ ))
2827
+ break
2828
+
2829
+ if phase.delay_after_ms > 0 and i < len(plan.phases) - 1:
2830
+ log.info("Waiting %dms before next phase...", phase.delay_after_ms)
2831
+ await asyncio.sleep(phase.delay_after_ms / 1000)
2832
+
2833
+ return all_results
2834
+
2835
+
2836
+ # ── Occupancy Engine ──────────────────────────────────────────────────────────
2837
+
2838
+ class OccupancyEngine:
2839
+ """
2840
+ Infers home occupancy state by aggregating signals from multiple
2841
+ context providers. Never relies on a single source — combines and weights them.
2842
+
2843
+ Supported signals and their default weights:
2844
+ Phone GPS outside perimeter → absence, weight 0.9
2845
+ Phone WiFi disconnected → absence, weight 0.7
2846
+ No PIR motion for 30+ min → absence, weight 0.4
2847
+ Smartwatch GPS outside perimeter → absence, weight 0.8
2848
+ Smart TV off → absence, weight 0.2
2849
+ """
2850
+
2851
+ def __init__(self):
2852
+ self._signals: list[PresenceSignal] = []
2853
+ self._signal_ttl_seconds = 300 # signals expire after 5 minutes
2854
+
2855
+ def update(self, signal: PresenceSignal) -> None:
2856
+ """Register or update a presence signal."""
2857
+ # Replace any previous signal from the same device
2858
+ self._signals = [
2859
+ s for s in self._signals
2860
+ if not (s.device_id == signal.device_id and
2861
+ s.signal_type == signal.signal_type)
2862
+ ]
2863
+ self._signals.append(signal)
2864
+ log.info(
2865
+ "Presence signal: %s / %s → present=%s (confidence=%.2f)",
2866
+ signal.device_id, signal.signal_type.value,
2867
+ signal.present, signal.confidence,
2868
+ )
2869
+
2870
+ def _active_signals(self) -> list[PresenceSignal]:
2871
+ """Filter out expired signals."""
2872
+ cutoff = time.time() - self._signal_ttl_seconds
2873
+ return [s for s in self._signals if s.timestamp >= cutoff]
2874
+
2875
+ def get_occupancy(self) -> OccupancyState:
2876
+ """
2877
+ Calcula el estado de ocupacion actual.
2878
+ Retorna occupied=True si la confianza ponderada de presencia >= 0.5.
2879
+ """
2880
+ signals = self._active_signals()
2881
+ if not signals:
2882
+ # No signals = unknown state; default to occupied for safety
2883
+ return OccupancyState(
2884
+ occupied=True,
2885
+ confidence=0.0,
2886
+ members_home=[],
2887
+ signals_used=0,
2888
+ )
2889
+
2890
+ # Calculate weighted presence confidence
2891
+ total_weight = sum(s.confidence for s in signals)
2892
+ presence_weight = sum(
2893
+ s.confidence for s in signals if s.present
2894
+ )
2895
+
2896
+ confidence_present = presence_weight / total_weight if total_weight > 0 else 0.5
2897
+ occupied = confidence_present >= 0.5
2898
+
2899
+ members_home = list({
2900
+ s.member_id for s in signals
2901
+ if s.present and s.member_id
2902
+ })
2903
+
2904
+ return OccupancyState(
2905
+ occupied=occupied,
2906
+ confidence=abs(confidence_present - 0.5) * 2, # 0=incertidumbre, 1=certeza
2907
+ members_home=members_home,
2908
+ signals_used=len(signals),
2909
+ )
2910
+
2911
+ def all_signals(self) -> list[dict]:
2912
+ return [
2913
+ {
2914
+ "device_id": s.device_id,
2915
+ "signal_type": s.signal_type.value,
2916
+ "present": s.present,
2917
+ "confidence": s.confidence,
2918
+ "member_id": s.member_id,
2919
+ "timestamp": s.timestamp,
2920
+ "age_seconds": round(time.time() - s.timestamp, 1),
2921
+ }
2922
+ for s in self._active_signals()
2923
+ ]