dosync 0.7.0__tar.gz → 0.8.0__tar.gz

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.
Files changed (212) hide show
  1. {dosync-0.7.0 → dosync-0.8.0}/PKG-INFO +4 -4
  2. {dosync-0.7.0 → dosync-0.8.0}/README.md +3 -3
  3. {dosync-0.7.0 → dosync-0.8.0}/dosync/__init__.py +1 -1
  4. {dosync-0.7.0 → dosync-0.8.0}/dosync/certify.py +90 -7
  5. {dosync-0.7.0 → dosync-0.8.0}/dosync/declarative.py +12 -2
  6. {dosync-0.7.0 → dosync-0.8.0}/dosync/hub.py +26 -1
  7. {dosync-0.7.0 → dosync-0.8.0}/dosync/mcp_server.py +24 -0
  8. {dosync-0.7.0 → dosync-0.8.0}/dosync/models.py +73 -0
  9. {dosync-0.7.0 → dosync-0.8.0}/dosync/resolvers.py +137 -15
  10. {dosync-0.7.0 → dosync-0.8.0}/dosync/server.py +104 -7
  11. {dosync-0.7.0 → dosync-0.8.0}/dosync.egg-info/PKG-INFO +4 -4
  12. {dosync-0.7.0 → dosync-0.8.0}/dosync.egg-info/SOURCES.txt +8 -0
  13. dosync-0.8.0/tests/test_agent_lab_matches_the_hub.py +130 -0
  14. dosync-0.8.0/tests/test_capability_decides_participation.py +60 -0
  15. dosync-0.8.0/tests/test_emergency_actions.py +111 -0
  16. dosync-0.8.0/tests/test_evaluation_tools_run.py +44 -0
  17. {dosync-0.7.0 → dosync-0.8.0}/tests/test_explain_consistency.py +6 -1
  18. dosync-0.8.0/tests/test_governance_vocabulary.py +33 -0
  19. dosync-0.8.0/tests/test_governed_direct_mode.py +144 -0
  20. {dosync-0.7.0 → dosync-0.8.0}/tests/test_measurement_tools_are_honest.py +13 -2
  21. {dosync-0.7.0 → dosync-0.8.0}/tests/test_no_operator_data.py +3 -0
  22. dosync-0.8.0/tests/test_opposite_actions_and_emergency_precedence.py +95 -0
  23. {dosync-0.7.0 → dosync-0.8.0}/tests/test_resolution_wiring.py +8 -2
  24. {dosync-0.7.0 → dosync-0.8.0}/tests/test_resolver_semantics.py +9 -5
  25. {dosync-0.7.0 → dosync-0.8.0}/tests/test_schemas_match_the_hub.py +1 -0
  26. dosync-0.8.0/tests/test_thing_description_export.py +55 -0
  27. {dosync-0.7.0 → dosync-0.8.0}/LICENSE +0 -0
  28. {dosync-0.7.0 → dosync-0.8.0}/dosync/adapter_drafting.py +0 -0
  29. {dosync-0.7.0 → dosync-0.8.0}/dosync/adapters/__init__.py +0 -0
  30. {dosync-0.7.0 → dosync-0.8.0}/dosync/adapters/ble.py +0 -0
  31. {dosync-0.7.0 → dosync-0.8.0}/dosync/adapters/declarative.py +0 -0
  32. {dosync-0.7.0 → dosync-0.8.0}/dosync/adapters/homeassistant.py +0 -0
  33. {dosync-0.7.0 → dosync-0.8.0}/dosync/adapters/matter.py +0 -0
  34. {dosync-0.7.0 → dosync-0.8.0}/dosync/adapters/mavlink.py +0 -0
  35. {dosync-0.7.0 → dosync-0.8.0}/dosync/adapters/mqtt.py +0 -0
  36. {dosync-0.7.0 → dosync-0.8.0}/dosync/adapters/notifications.py +0 -0
  37. {dosync-0.7.0 → dosync-0.8.0}/dosync/adapters/shelly.py +0 -0
  38. {dosync-0.7.0 → dosync-0.8.0}/dosync/adapters/wiz.py +0 -0
  39. {dosync-0.7.0 → dosync-0.8.0}/dosync/audit.py +0 -0
  40. {dosync-0.7.0 → dosync-0.8.0}/dosync/audit_backup.py +0 -0
  41. {dosync-0.7.0 → dosync-0.8.0}/dosync/auth.py +0 -0
  42. {dosync-0.7.0 → dosync-0.8.0}/dosync/auth_fastapi.py +0 -0
  43. {dosync-0.7.0 → dosync-0.8.0}/dosync/cert_signing.py +0 -0
  44. {dosync-0.7.0 → dosync-0.8.0}/dosync/cli.py +0 -0
  45. {dosync-0.7.0 → dosync-0.8.0}/dosync/composite_executor.py +0 -0
  46. {dosync-0.7.0 → dosync-0.8.0}/dosync/composite_operations.py +0 -0
  47. {dosync-0.7.0 → dosync-0.8.0}/dosync/config_reference.py +0 -0
  48. {dosync-0.7.0 → dosync-0.8.0}/dosync/dashboard.html +0 -0
  49. {dosync-0.7.0 → dosync-0.8.0}/dosync/db.py +0 -0
  50. {dosync-0.7.0 → dosync-0.8.0}/dosync/device_arbiter.py +0 -0
  51. {dosync-0.7.0 → dosync-0.8.0}/dosync/discoverers.py +0 -0
  52. {dosync-0.7.0 → dosync-0.8.0}/dosync/discoverers_mdns.py +0 -0
  53. {dosync-0.7.0 → dosync-0.8.0}/dosync/discoverers_ssdp.py +0 -0
  54. {dosync-0.7.0 → dosync-0.8.0}/dosync/discovery.py +0 -0
  55. {dosync-0.7.0 → dosync-0.8.0}/dosync/ed25519_pure.py +0 -0
  56. {dosync-0.7.0 → dosync-0.8.0}/dosync/examples/__init__.py +0 -0
  57. {dosync-0.7.0 → dosync-0.8.0}/dosync/examples/declarative/3d-printer.yaml +0 -0
  58. {dosync-0.7.0 → dosync-0.8.0}/dosync/examples/declarative/air-conditioner.yaml +0 -0
  59. {dosync-0.7.0 → dosync-0.8.0}/dosync/examples/declarative/building-lighting.json +0 -0
  60. {dosync-0.7.0 → dosync-0.8.0}/dosync/examples/declarative/industrial-conveyor.yaml +0 -0
  61. {dosync-0.7.0 → dosync-0.8.0}/dosync/examples/declarative/light-generic.yaml +0 -0
  62. {dosync-0.7.0 → dosync-0.8.0}/dosync/examples/declarative/television.yaml +0 -0
  63. {dosync-0.7.0 → dosync-0.8.0}/dosync/execution.py +0 -0
  64. {dosync-0.7.0 → dosync-0.8.0}/dosync/executor.py +0 -0
  65. {dosync-0.7.0 → dosync-0.8.0}/dosync/geo.py +0 -0
  66. {dosync-0.7.0 → dosync-0.8.0}/dosync/hub_monitor.py +0 -0
  67. {dosync-0.7.0 → dosync-0.8.0}/dosync/lightweight.py +0 -0
  68. {dosync-0.7.0 → dosync-0.8.0}/dosync/manage.py +0 -0
  69. {dosync-0.7.0 → dosync-0.8.0}/dosync/metrics.py +0 -0
  70. {dosync-0.7.0 → dosync-0.8.0}/dosync/occupancy.py +0 -0
  71. {dosync-0.7.0 → dosync-0.8.0}/dosync/operation_guards.py +0 -0
  72. {dosync-0.7.0 → dosync-0.8.0}/dosync/operation_supervisor.py +0 -0
  73. {dosync-0.7.0 → dosync-0.8.0}/dosync/operations.py +0 -0
  74. {dosync-0.7.0 → dosync-0.8.0}/dosync/paths.py +0 -0
  75. {dosync-0.7.0 → dosync-0.8.0}/dosync/plan_executor.py +0 -0
  76. {dosync-0.7.0 → dosync-0.8.0}/dosync/plugins.py +0 -0
  77. {dosync-0.7.0 → dosync-0.8.0}/dosync/policies.py +0 -0
  78. {dosync-0.7.0 → dosync-0.8.0}/dosync/policy_config.py +0 -0
  79. {dosync-0.7.0 → dosync-0.8.0}/dosync/py.typed +0 -0
  80. {dosync-0.7.0 → dosync-0.8.0}/dosync/reconciler.py +0 -0
  81. {dosync-0.7.0 → dosync-0.8.0}/dosync/registry.py +0 -0
  82. {dosync-0.7.0 → dosync-0.8.0}/dosync/restore.py +0 -0
  83. {dosync-0.7.0 → dosync-0.8.0}/dosync/route_composer.py +0 -0
  84. {dosync-0.7.0 → dosync-0.8.0}/dosync/security.py +0 -0
  85. {dosync-0.7.0 → dosync-0.8.0}/dosync/spec/TAG-VOCABULARY.md +0 -0
  86. {dosync-0.7.0 → dosync-0.8.0}/dosync/spec_coverage.py +0 -0
  87. {dosync-0.7.0 → dosync-0.8.0}/dosync/state_refresh.py +0 -0
  88. {dosync-0.7.0 → dosync-0.8.0}/dosync/telemetry.py +0 -0
  89. {dosync-0.7.0 → dosync-0.8.0}/dosync/templates/adapter-draft-prompt.md +0 -0
  90. {dosync-0.7.0 → dosync-0.8.0}/dosync/validation.py +0 -0
  91. {dosync-0.7.0 → dosync-0.8.0}/dosync.egg-info/dependency_links.txt +0 -0
  92. {dosync-0.7.0 → dosync-0.8.0}/dosync.egg-info/entry_points.txt +0 -0
  93. {dosync-0.7.0 → dosync-0.8.0}/dosync.egg-info/requires.txt +0 -0
  94. {dosync-0.7.0 → dosync-0.8.0}/dosync.egg-info/top_level.txt +0 -0
  95. {dosync-0.7.0 → dosync-0.8.0}/pyproject.toml +0 -0
  96. {dosync-0.7.0 → dosync-0.8.0}/setup.cfg +0 -0
  97. {dosync-0.7.0 → dosync-0.8.0}/tests/test_adapter_drafting.py +0 -0
  98. {dosync-0.7.0 → dosync-0.8.0}/tests/test_adapters.py +0 -0
  99. {dosync-0.7.0 → dosync-0.8.0}/tests/test_adapters_speak_the_protocol.py +0 -0
  100. {dosync-0.7.0 → dosync-0.8.0}/tests/test_audit_archive.py +0 -0
  101. {dosync-0.7.0 → dosync-0.8.0}/tests/test_audit_backup.py +0 -0
  102. {dosync-0.7.0 → dosync-0.8.0}/tests/test_audit_chain_integrity.py +0 -0
  103. {dosync-0.7.0 → dosync-0.8.0}/tests/test_audit_chain_ordering.py +0 -0
  104. {dosync-0.7.0 → dosync-0.8.0}/tests/test_audit_concurrency.py +0 -0
  105. {dosync-0.7.0 → dosync-0.8.0}/tests/test_audit_endpoint_limit.py +0 -0
  106. {dosync-0.7.0 → dosync-0.8.0}/tests/test_audit_hashless.py +0 -0
  107. {dosync-0.7.0 → dosync-0.8.0}/tests/test_audit_provenance.py +0 -0
  108. {dosync-0.7.0 → dosync-0.8.0}/tests/test_auth.py +0 -0
  109. {dosync-0.7.0 → dosync-0.8.0}/tests/test_ble_adapter.py +0 -0
  110. {dosync-0.7.0 → dosync-0.8.0}/tests/test_certification_guide.py +0 -0
  111. {dosync-0.7.0 → dosync-0.8.0}/tests/test_certification_honesty.py +0 -0
  112. {dosync-0.7.0 → dosync-0.8.0}/tests/test_check_helpers_can_fail.py +0 -0
  113. {dosync-0.7.0 → dosync-0.8.0}/tests/test_claim_state_machine.py +0 -0
  114. {dosync-0.7.0 → dosync-0.8.0}/tests/test_collaborators_read_the_live_hub.py +0 -0
  115. {dosync-0.7.0 → dosync-0.8.0}/tests/test_composite_executor.py +0 -0
  116. {dosync-0.7.0 → dosync-0.8.0}/tests/test_composite_operations.py +0 -0
  117. {dosync-0.7.0 → dosync-0.8.0}/tests/test_composite_orchestration.py +0 -0
  118. {dosync-0.7.0 → dosync-0.8.0}/tests/test_composition_kind_db.py +0 -0
  119. {dosync-0.7.0 → dosync-0.8.0}/tests/test_composition_kind_endpoint.py +0 -0
  120. {dosync-0.7.0 → dosync-0.8.0}/tests/test_composition_routing.py +0 -0
  121. {dosync-0.7.0 → dosync-0.8.0}/tests/test_dashboard_tells_the_truth.py +0 -0
  122. {dosync-0.7.0 → dosync-0.8.0}/tests/test_dashboard_uses_live_endpoints.py +0 -0
  123. {dosync-0.7.0 → dosync-0.8.0}/tests/test_db.py +0 -0
  124. {dosync-0.7.0 → dosync-0.8.0}/tests/test_db_write_serialization.py +0 -0
  125. {dosync-0.7.0 → dosync-0.8.0}/tests/test_declarative_adapters.py +0 -0
  126. {dosync-0.7.0 → dosync-0.8.0}/tests/test_declarative_location.py +0 -0
  127. {dosync-0.7.0 → dosync-0.8.0}/tests/test_declarative_quarantine.py +0 -0
  128. {dosync-0.7.0 → dosync-0.8.0}/tests/test_deployment_env_contract.py +0 -0
  129. {dosync-0.7.0 → dosync-0.8.0}/tests/test_deployment_layout.py +0 -0
  130. {dosync-0.7.0 → dosync-0.8.0}/tests/test_device_health.py +0 -0
  131. {dosync-0.7.0 → dosync-0.8.0}/tests/test_device_heartbeat.py +0 -0
  132. {dosync-0.7.0 → dosync-0.8.0}/tests/test_device_identity.py +0 -0
  133. {dosync-0.7.0 → dosync-0.8.0}/tests/test_device_location.py +0 -0
  134. {dosync-0.7.0 → dosync-0.8.0}/tests/test_direct_action_governance.py +0 -0
  135. {dosync-0.7.0 → dosync-0.8.0}/tests/test_direct_action_instrumented.py +0 -0
  136. {dosync-0.7.0 → dosync-0.8.0}/tests/test_discovery_adoption.py +0 -0
  137. {dosync-0.7.0 → dosync-0.8.0}/tests/test_documentation_is_navigable.py +0 -0
  138. {dosync-0.7.0 → dosync-0.8.0}/tests/test_drone_policies.py +0 -0
  139. {dosync-0.7.0 → dosync-0.8.0}/tests/test_ed25519_pure.py +0 -0
  140. {dosync-0.7.0 → dosync-0.8.0}/tests/test_emergency_preemption.py +0 -0
  141. {dosync-0.7.0 → dosync-0.8.0}/tests/test_env_loading_is_explicit.py +0 -0
  142. {dosync-0.7.0 → dosync-0.8.0}/tests/test_evaluation_metrics.py +0 -0
  143. {dosync-0.7.0 → dosync-0.8.0}/tests/test_event_loop_migration.py +0 -0
  144. {dosync-0.7.0 → dosync-0.8.0}/tests/test_explain_resolve_parity.py +0 -0
  145. {dosync-0.7.0 → dosync-0.8.0}/tests/test_extraction_preserves_behaviour.py +0 -0
  146. {dosync-0.7.0 → dosync-0.8.0}/tests/test_geo.py +0 -0
  147. {dosync-0.7.0 → dosync-0.8.0}/tests/test_ha_bridge_hygiene.py +0 -0
  148. {dosync-0.7.0 → dosync-0.8.0}/tests/test_ha_read_sensors.py +0 -0
  149. {dosync-0.7.0 → dosync-0.8.0}/tests/test_health_recorded_once.py +0 -0
  150. {dosync-0.7.0 → dosync-0.8.0}/tests/test_hub_monitor.py +0 -0
  151. {dosync-0.7.0 → dosync-0.8.0}/tests/test_idempotency.py +0 -0
  152. {dosync-0.7.0 → dosync-0.8.0}/tests/test_independent_observation.py +0 -0
  153. {dosync-0.7.0 → dosync-0.8.0}/tests/test_integration_suite.py +0 -0
  154. {dosync-0.7.0 → dosync-0.8.0}/tests/test_intent_rejections_visible.py +0 -0
  155. {dosync-0.7.0 → dosync-0.8.0}/tests/test_lightweight_heartbeat.py +0 -0
  156. {dosync-0.7.0 → dosync-0.8.0}/tests/test_location_restricts.py +0 -0
  157. {dosync-0.7.0 → dosync-0.8.0}/tests/test_mavlink_adapter.py +0 -0
  158. {dosync-0.7.0 → dosync-0.8.0}/tests/test_mavlink_channels.py +0 -0
  159. {dosync-0.7.0 → dosync-0.8.0}/tests/test_mavlink_listener.py +0 -0
  160. {dosync-0.7.0 → dosync-0.8.0}/tests/test_mavlink_return_home.py +0 -0
  161. {dosync-0.7.0 → dosync-0.8.0}/tests/test_mavlink_single_reader.py +0 -0
  162. {dosync-0.7.0 → dosync-0.8.0}/tests/test_mavlink_telemetry_closure.py +0 -0
  163. {dosync-0.7.0 → dosync-0.8.0}/tests/test_mcp_dynamic_intents.py +0 -0
  164. {dosync-0.7.0 → dosync-0.8.0}/tests/test_mcp_http_transport.py +0 -0
  165. {dosync-0.7.0 → dosync-0.8.0}/tests/test_mcp_partial_progress.py +0 -0
  166. {dosync-0.7.0 → dosync-0.8.0}/tests/test_mcp_scenarios_are_live.py +0 -0
  167. {dosync-0.7.0 → dosync-0.8.0}/tests/test_mcp_speaks_english.py +0 -0
  168. {dosync-0.7.0 → dosync-0.8.0}/tests/test_mcp_status.py +0 -0
  169. {dosync-0.7.0 → dosync-0.8.0}/tests/test_metrics.py +0 -0
  170. {dosync-0.7.0 → dosync-0.8.0}/tests/test_models.py +0 -0
  171. {dosync-0.7.0 → dosync-0.8.0}/tests/test_multihub_endpoints.py +0 -0
  172. {dosync-0.7.0 → dosync-0.8.0}/tests/test_no_undefined_names.py +0 -0
  173. {dosync-0.7.0 → dosync-0.8.0}/tests/test_notification_templates.py +0 -0
  174. {dosync-0.7.0 → dosync-0.8.0}/tests/test_occupancy.py +0 -0
  175. {dosync-0.7.0 → dosync-0.8.0}/tests/test_openapi_contract.py +0 -0
  176. {dosync-0.7.0 → dosync-0.8.0}/tests/test_operation_guards.py +0 -0
  177. {dosync-0.7.0 → dosync-0.8.0}/tests/test_operation_supervisor.py +0 -0
  178. {dosync-0.7.0 → dosync-0.8.0}/tests/test_operations.py +0 -0
  179. {dosync-0.7.0 → dosync-0.8.0}/tests/test_operations_endpoints.py +0 -0
  180. {dosync-0.7.0 → dosync-0.8.0}/tests/test_operations_persistence.py +0 -0
  181. {dosync-0.7.0 → dosync-0.8.0}/tests/test_operations_wiring.py +0 -0
  182. {dosync-0.7.0 → dosync-0.8.0}/tests/test_panel_polish_2026_07_21.py +0 -0
  183. {dosync-0.7.0 → dosync-0.8.0}/tests/test_plan_executor.py +0 -0
  184. {dosync-0.7.0 → dosync-0.8.0}/tests/test_policies.py +0 -0
  185. {dosync-0.7.0 → dosync-0.8.0}/tests/test_policy_config.py +0 -0
  186. {dosync-0.7.0 → dosync-0.8.0}/tests/test_protocol_version.py +0 -0
  187. {dosync-0.7.0 → dosync-0.8.0}/tests/test_public_claims.py +0 -0
  188. {dosync-0.7.0 → dosync-0.8.0}/tests/test_pushed_verification.py +0 -0
  189. {dosync-0.7.0 → dosync-0.8.0}/tests/test_quickstart_is_runnable.py +0 -0
  190. {dosync-0.7.0 → dosync-0.8.0}/tests/test_reachability_cause.py +0 -0
  191. {dosync-0.7.0 → dosync-0.8.0}/tests/test_recall_benchmark_postpolicy.py +0 -0
  192. {dosync-0.7.0 → dosync-0.8.0}/tests/test_reconciler.py +0 -0
  193. {dosync-0.7.0 → dosync-0.8.0}/tests/test_registry.py +0 -0
  194. {dosync-0.7.0 → dosync-0.8.0}/tests/test_resolver_scoring.py +0 -0
  195. {dosync-0.7.0 → dosync-0.8.0}/tests/test_route_composer.py +0 -0
  196. {dosync-0.7.0 → dosync-0.8.0}/tests/test_sensor_kind.py +0 -0
  197. {dosync-0.7.0 → dosync-0.8.0}/tests/test_server.py +0 -0
  198. {dosync-0.7.0 → dosync-0.8.0}/tests/test_server_enforces_auth.py +0 -0
  199. {dosync-0.7.0 → dosync-0.8.0}/tests/test_simulation_is_declared.py +0 -0
  200. {dosync-0.7.0 → dosync-0.8.0}/tests/test_state_refresh.py +0 -0
  201. {dosync-0.7.0 → dosync-0.8.0}/tests/test_tag_vocabulary.py +0 -0
  202. {dosync-0.7.0 → dosync-0.8.0}/tests/test_telemetry.py +0 -0
  203. {dosync-0.7.0 → dosync-0.8.0}/tests/test_telemetry_bridge.py +0 -0
  204. {dosync-0.7.0 → dosync-0.8.0}/tests/test_third_party_adapters.py +0 -0
  205. {dosync-0.7.0 → dosync-0.8.0}/tests/test_transport_discoverers.py +0 -0
  206. {dosync-0.7.0 → dosync-0.8.0}/tests/test_undefined_name_regressions.py +0 -0
  207. {dosync-0.7.0 → dosync-0.8.0}/tests/test_universal_intent_contract.py +0 -0
  208. {dosync-0.7.0 → dosync-0.8.0}/tests/test_unknown_location_refused.py +0 -0
  209. {dosync-0.7.0 → dosync-0.8.0}/tests/test_validation.py +0 -0
  210. {dosync-0.7.0 → dosync-0.8.0}/tests/test_validation_integration.py +0 -0
  211. {dosync-0.7.0 → dosync-0.8.0}/tests/test_wiring_audit.py +0 -0
  212. {dosync-0.7.0 → dosync-0.8.0}/tests/test_wiz_closes_every_bulb.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dosync
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: The semantic layer between AI agents and physical devices
5
5
  Author-email: Rodrigo Giuliani <rgiuliani@dosync.dev>
6
6
  License-Expression: Apache-2.0
@@ -961,7 +961,7 @@ the other end.
961
961
  | Web dashboard | ✅ |
962
962
  | API key authentication + SHA-256 audit log | ✅ |
963
963
  | Capability-based resolver | ✅ |
964
- | Certification CLI — Basic 10 · Standard 33 · Emergency 44 · Conformance 65, each run in CI against a live hub (signed reports) | ✅ |
964
+ | Certification CLI — Basic 10 · Standard 33 · Emergency 44 · Conformance 69, each run in CI against a live hub (signed reports) | ✅ |
965
965
  | Philips WiZ adapter (UDP local) | ✅ |
966
966
  | Home Assistant bridge (10 domains) | ✅ |
967
967
  | Native MCP server (Claude, ChatGPT, any LLM) | ✅ |
@@ -1014,8 +1014,8 @@ python3 certify.py --host <hub-ip> --port 47200 --tier standard
1014
1014
 
1015
1015
  | Language | Location | Author | Certification |
1016
1016
  |---|---|---|---|
1017
- | Python (reference) | `server.py` | this project | Conformance 65/65 ✅ (every tier, in CI) |
1018
- | Node.js (companion) | [giulianireg-spec/dosync-node](https://github.com/giulianireg-spec/dosync-node) | this project | Standard 33/33, against the v0.3 suite — re-validation against the current 65-check suite pending |
1017
+ | Python (reference) | `server.py` | this project | Conformance 69/69 ✅ (every tier, in CI) |
1018
+ | Node.js (companion) | [giulianireg-spec/dosync-node](https://github.com/giulianireg-spec/dosync-node) | this project | Standard 33/33, against the v0.3 suite — re-validation against the current 69-check suite pending |
1019
1019
 
1020
1020
  The Node.js implementation is a **companion** port that validates the protocol
1021
1021
  is implementable in a second language against the same certification suite —
@@ -895,7 +895,7 @@ the other end.
895
895
  | Web dashboard | ✅ |
896
896
  | API key authentication + SHA-256 audit log | ✅ |
897
897
  | Capability-based resolver | ✅ |
898
- | Certification CLI — Basic 10 · Standard 33 · Emergency 44 · Conformance 65, each run in CI against a live hub (signed reports) | ✅ |
898
+ | Certification CLI — Basic 10 · Standard 33 · Emergency 44 · Conformance 69, each run in CI against a live hub (signed reports) | ✅ |
899
899
  | Philips WiZ adapter (UDP local) | ✅ |
900
900
  | Home Assistant bridge (10 domains) | ✅ |
901
901
  | Native MCP server (Claude, ChatGPT, any LLM) | ✅ |
@@ -948,8 +948,8 @@ python3 certify.py --host <hub-ip> --port 47200 --tier standard
948
948
 
949
949
  | Language | Location | Author | Certification |
950
950
  |---|---|---|---|
951
- | Python (reference) | `server.py` | this project | Conformance 65/65 ✅ (every tier, in CI) |
952
- | Node.js (companion) | [giulianireg-spec/dosync-node](https://github.com/giulianireg-spec/dosync-node) | this project | Standard 33/33, against the v0.3 suite — re-validation against the current 65-check suite pending |
951
+ | Python (reference) | `server.py` | this project | Conformance 69/69 ✅ (every tier, in CI) |
952
+ | Node.js (companion) | [giulianireg-spec/dosync-node](https://github.com/giulianireg-spec/dosync-node) | this project | Standard 33/33, against the v0.3 suite — re-validation against the current 69-check suite pending |
953
953
 
954
954
  The Node.js implementation is a **companion** port that validates the protocol
955
955
  is implementable in a second language against the same certification suite —
@@ -13,5 +13,5 @@ The two numbers move independently on purpose:
13
13
  __version__ this implementation of the hub (semver)
14
14
  __protocol_version__ the wire contract other implementations must match
15
15
  """
16
- __version__ = "0.7.0"
16
+ __version__ = "0.8.0"
17
17
  __protocol_version__ = "0.5"
@@ -9,7 +9,7 @@ Tiers:
9
9
  basic (10 tests) — connectivity, authentication, device manifest
10
10
  standard (33 tests) — protocol conformance, events, health, version headers, manifest privacy, intent lifecycle
11
11
  emergency (44 tests) — everything in standard + emergency override, policy engine, audit log integrity, firmware re-registration
12
- conformance (65 tests) — everything in emergency + protocol features through 0.5: sensor kind, policy provenance,
12
+ conformance (69 tests) — everything in emergency + protocol features through 0.5: sensor kind, policy provenance,
13
13
  chain archiving, adapters, discovery, and where a device is and what a location in an intent does
14
14
 
15
15
  Two testing modes:
@@ -261,7 +261,7 @@ class CertReport:
261
261
  # basic said 12 while the tier runs B01-B10: a basic certification was
262
262
  # always "incomplete" and could never certify. Nothing noticed because
263
263
  # nothing ran the suite; CI now certifies every tier on its own.
264
- "basic": 10, "standard": 33, "emergency": 44, "conformance": 65,
264
+ "basic": 10, "standard": 33, "emergency": 44, "conformance": 69,
265
265
  }
266
266
 
267
267
  def finalize(self):
@@ -512,7 +512,10 @@ def run_standard(base: str, report: CertReport):
512
512
  status4, body4 = fire_intent_conformance(base, {
513
513
  "intent": "control_access",
514
514
  "urgency": "alert",
515
- "context": {"trigger": "certification_test"},
515
+ # control_access asks for lock and unlock; an intent says which it means
516
+ # (protocol 0.5 rev. 2026-10-02). Before this, S4 asked a hub's locks
517
+ # for both at once.
518
+ "context": {"trigger": "certification_test", "action_types": ["lock"]},
516
519
  })
517
520
  report.add(TestResult(
518
521
  "S04 Hub accepts alert urgency (control_access [alert])",
@@ -922,7 +925,7 @@ def run_emergency(base: str, report: CertReport):
922
925
  ))
923
926
 
924
927
 
925
- # ── TIER CONFORMANCE — protocol features through 0.5 (cumulative: 65) ─────────
928
+ # ── TIER CONFORMANCE — protocol features through 0.5 (cumulative: 69) ─────────
926
929
  # Everything shipped in the 0.4 cycle (SENSOR-KIND, AUDIT-PROVENANCE,
927
930
  # EMERGENCY-UNSAT-ESCALATION, AUDIT-ARCHIVE) had unit tests but no CONFORMANCE
928
931
  # coverage — nothing proved, over the wire against a running hub, that the
@@ -1088,7 +1091,7 @@ def run_conformance(base: str, report: CertReport):
1088
1091
  run_conformance_05(base, report)
1089
1092
 
1090
1093
 
1091
- # C13-C21 cover protocol 0.5 (spec §10.5): where a device is, and what a
1094
+ # C13-C25 cover protocol 0.5 (spec §10.5): where a device is, and what a
1092
1095
  # location in an intent's context does. The suite builds its own scene -- two
1093
1096
  # probe devices and two probe intent classes, prefixed certify- -- and removes it
1094
1097
  # whatever happens. It verifies through explain(), which executes nothing,
@@ -1097,6 +1100,8 @@ def run_conformance(base: str, report: CertReport):
1097
1100
  _PROBE_ACTION = "certify_probe"
1098
1101
  _PROBE_IN = "certify-probe-cell-2" # placed at certify-site/line-3/cell-2
1099
1102
  _PROBE_OUT = "certify-probe-line-30" # placed at certify-site/line-30/cell-1
1103
+ _PROBE_LOCK = "certify-probe-lock" # emergency-capable, lock and unlock (C22)
1104
+ _PROBE_CONVEYOR = "certify-probe-conveyor" # emergency-capable, declares stop (C24)
1100
1105
  _CLASS_RESTRICTS = "certify_location_restricts"
1101
1106
  _CLASS_INFORMS = "certify_location_informs"
1102
1107
 
@@ -1216,8 +1221,86 @@ def run_conformance_05(base: str, report: CertReport):
1216
1221
  "as CI does, to check it")
1217
1222
  report.not_applicable.append((name, reason))
1218
1223
  warn(f"{name} — not applicable in production mode: {reason}")
1224
+ # C22. In an emergency, an emergency-capable device the class asks
1225
+ # nothing of does what it declared -- never every capability (a lock
1226
+ # used to receive lock AND unlock at once). Checked through explain and
1227
+ # PATCH: nothing is fired.
1228
+ lock = _probe_device(_PROBE_LOCK)
1229
+ lock.update({"emergency_capable": True, "tags": ["certify-probe"],
1230
+ "actuators": [{"id": "lock", "type": "lock", "description": "Lock"},
1231
+ {"id": "unlock", "type": "unlock", "description": "Unlock"}]})
1232
+ request("POST", f"{base}/v1/devices/register", lock)
1233
+
1234
+ def _lock_entry():
1235
+ st, body = request("GET", f"{base}/v1/intents/ensure_safety/explain?urgency=emergency")
1236
+ return next((d for d in body.get("included", []) if d.get("device_id") == _PROBE_LOCK), {}) if st == 200 else {}
1237
+ before = _lock_entry()
1238
+ c_st, _ = request("PATCH", f"{base}/v1/devices/{_PROBE_LOCK}", {"emergency_actions": ["lock", "unlock"]})
1239
+ request("PATCH", f"{base}/v1/devices/{_PROBE_LOCK}", {"emergency_actions": ["unlock"]})
1240
+ after = _lock_entry()
1241
+ report.add(TestResult(
1242
+ "C22 An emergency-capable device does only its declared emergency actions",
1243
+ before.get("included_without_action") is True and c_st == 422
1244
+ and after.get("emergency_actions") == ["unlock"],
1245
+ f"undeclared: {'no action' if before.get('included_without_action') else before}; "
1246
+ f"lock+unlock → HTTP {c_st}; declared: {after.get('emergency_actions')}"))
1247
+ # C23. An intent whose class asks for opposite actions must say which it
1248
+ # means; outside an emergency the hub refuses it before executing
1249
+ # anything. Nothing is fired: both requests are refused.
1250
+ a_st, a_body = request("POST", f"{base}/v1/intent/async",
1251
+ {"intent": "control_access", "urgency": "alert", "context": {}})
1252
+ i_st, _ = request("POST", f"{base}/v1/intent/async",
1253
+ {"intent": "control_access", "urgency": "alert",
1254
+ "context": {"action_types": ["certify-no-such-action"]}})
1255
+ report.add(TestResult(
1256
+ "C23 An intent asking for opposite actions must say which (422)",
1257
+ a_st == 422 and "action_types" in str(a_body) and i_st == 422,
1258
+ f"ambiguous → HTTP {a_st}; outside the class → HTTP {i_st}"))
1259
+
1260
+ # C24. In an emergency, what a device declared comes first: a conveyor
1261
+ # declared to stop is not turned on by a class that asks for turn_on.
1262
+ conveyor = _probe_device(_PROBE_CONVEYOR)
1263
+ conveyor.update({"emergency_capable": True, "tags": ["certify-probe"],
1264
+ "emergency_actions": [{"action": "stop"}],
1265
+ "actuators": [{"id": "turn_on", "type": "turn_on", "description": "Start"},
1266
+ {"id": "stop", "type": "stop", "description": "Stop"}]})
1267
+ request("POST", f"{base}/v1/devices/register", conveyor)
1268
+ st, body = request("GET", f"{base}/v1/intents/ensure_safety/explain?urgency=emergency")
1269
+ entry = next((d for d in (body.get("included", []) if st == 200 else [])
1270
+ if d.get("device_id") == _PROBE_CONVEYOR), {})
1271
+ report.add(TestResult(
1272
+ "C24 In an emergency a device's declared actions come first",
1273
+ entry.get("emergency_actions") == ["stop"],
1274
+ f"explain reports {entry.get('emergency_actions') or entry or 'nothing'}"))
1275
+ # C25. Governed direct mode: the hub validates what an agent proposes.
1276
+ # Every proposal here is invalid, so nothing runs -- an opposite pair, an
1277
+ # undeclared action and an unknown device in one intent, an action
1278
+ # outside the class in another -- and each refusal carries its reason.
1279
+ def _refusals(body):
1280
+ st, fired = request("POST", f"{base}/v1/intent/async", body)
1281
+ if st != 200 or not fired.get("intent_id"):
1282
+ return None, f"HTTP {st}"
1283
+ time.sleep(2)
1284
+ _, res = request("GET", f"{base}/v1/intent/{fired['intent_id']}")
1285
+ return res, {(r.get("device_id"), r.get("action")): r.get("reason")
1286
+ for r in res.get("refused_proposals") or []}
1287
+ r1, got1 = _refusals({"intent": "control_access", "urgency": "alert", "context": {"proposed_actions": [
1288
+ {"device_id": _PROBE_LOCK, "action": "lock"}, {"device_id": _PROBE_LOCK, "action": "unlock"},
1289
+ {"device_id": _PROBE_LOCK, "action": "certify-fly"},
1290
+ {"device_id": "certify-no-such-device", "action": "lock"}]}})
1291
+ r2, got2 = _refusals({"intent": "notify", "urgency": "info", "context": {"proposed_actions": [
1292
+ {"device_id": _PROBE_LOCK, "action": "unlock"}]}})
1293
+ want1 = {(_PROBE_LOCK, "lock"): "opposite_actions", (_PROBE_LOCK, "unlock"): "opposite_actions",
1294
+ (_PROBE_LOCK, "certify-fly"): "not_declared",
1295
+ ("certify-no-such-device", "lock"): "unknown_device"}
1296
+ nothing_ran = all(isinstance(r, dict) and not (r.get("results") or []) for r in (r1, r2))
1297
+ report.add(TestResult(
1298
+ "C25 Proposed actions are validated; refused ones never run",
1299
+ got1 == want1 and got2 == {(_PROBE_LOCK, "unlock"): "outside_class"} and nothing_ran,
1300
+ f"refusals {sorted(set((got1 or {}).values()) | set((got2 or {}).values())) if isinstance(got1, dict) else got1}; "
1301
+ f"actions run: {0 if nothing_ran else 'some'}"))
1219
1302
  finally:
1220
- for did in (_PROBE_IN, _PROBE_OUT):
1303
+ for did in (_PROBE_IN, _PROBE_OUT, _PROBE_LOCK, _PROBE_CONVEYOR):
1221
1304
  request("DELETE", f"{base}/v1/devices/{did}")
1222
1305
  for name in (_CLASS_RESTRICTS, _CLASS_INFORMS):
1223
1306
  request("DELETE", f"{base}/v1/intent-classes/{name}")
@@ -1245,7 +1328,7 @@ Tier test counts:
1245
1328
  basic 10 tests — connectivity, auth, registration, manifest
1246
1329
  standard 33 tests — + intents, events, health, explainability, version headers, intent lifecycle
1247
1330
  emergency 44 tests — + emergency override, audit log integrity, firmware re-registration
1248
- conformance 65 tests — + protocol features through 0.5: sensor-kind, policy provenance, chain archiving,
1331
+ conformance 69 tests — + protocol features through 0.5: sensor-kind, policy provenance, chain archiving,
1249
1332
  device locations and location-restricted intents
1250
1333
  """,
1251
1334
  )
@@ -103,7 +103,7 @@ def build_manifest(data: dict, source: str = "<declarative>"):
103
103
  """
104
104
  from .models import (
105
105
  ActuatorSpec, CapabilityManifest, CertTier, DeviceCategory, SensorSpec,
106
- normalize_location,
106
+ normalize_emergency_actions, normalize_location,
107
107
  )
108
108
 
109
109
  device = data.get("device")
@@ -139,6 +139,14 @@ def build_manifest(data: dict, source: str = "<declarative>"):
139
139
  f"{source}: device declares location '{raw_location}' and room "
140
140
  f"'{raw_room}'. They are the same field; keep one.")
141
141
  raw = raw_location if raw_location is not None else raw_room
142
+ # What the device does in an emergency, declared by the file's author -- the
143
+ # operator (protocol 0.5, spec §6). Validated against the file's own actions.
144
+ try:
145
+ emergency_actions = normalize_emergency_actions(
146
+ device.get("emergency_actions"),
147
+ {str(a.get("type", n)) for n, a in (data.get("actions") or {}).items()})
148
+ except ValueError as e:
149
+ raise DeclarativeError(f"{source}: device.emergency_actions: {e}")
142
150
  if raw_location is None and raw_room is not None:
143
151
  # A file cannot receive the Deprecation header an API call gets, so the
144
152
  # deprecation (protocol 0.5, spec §10.5) is said where its author reads.
@@ -244,7 +252,7 @@ def build_manifest(data: dict, source: str = "<declarative>"):
244
252
  source, type(raw_provenance).__name__)
245
253
 
246
254
  return CapabilityManifest(
247
- provenance=provenance,
255
+ provenance={**provenance, **({"emergency_actions_set_by": "operator"} if emergency_actions else {})},
248
256
  device_id=device_id,
249
257
  device_name=name,
250
258
  manufacturer=str(device.get("manufacturer", "declarative")),
@@ -268,8 +276,10 @@ def build_manifest(data: dict, source: str = "<declarative>"):
268
276
  "actions": actions,
269
277
  "sensors": data.get("sensors") or {},
270
278
  **({"location_from_file": source} if location else {}),
279
+ **({"emergency_actions_from_file": source} if emergency_actions else {}),
271
280
  },
272
281
  location=location,
282
+ emergency_actions=emergency_actions,
273
283
  )
274
284
 
275
285
 
@@ -533,7 +533,29 @@ class DoSyncHub:
533
533
  intent, executor, composition_kind)
534
534
 
535
535
  _t0 = time.perf_counter()
536
- plan = self.resolver.resolve(intent)
536
+ if (intent.context or {}).get("proposed_actions"):
537
+ # Governed direct mode: validate what the agent proposed instead of
538
+ # resolving (spec §6 rule 7). An external resolver's local fallback
539
+ # applies the same guarantees.
540
+ _validate = (getattr(self.resolver, "validate_proposals", None)
541
+ or getattr(getattr(self.resolver, "_fallback", None), "validate_proposals", None))
542
+ if _validate is None:
543
+ from .models import ActionPlan as _APlan
544
+ plan = _APlan(intent_id=intent.intent_id, actions=[], urgency=intent.urgency,
545
+ refused_proposals=[{"device_id": p.get("device_id"), "action": p.get("action"),
546
+ "reason": "resolver_cannot_validate"}
547
+ for p in intent.context["proposed_actions"]])
548
+ else:
549
+ plan = _validate(intent)
550
+ else:
551
+ plan = self.resolver.resolve(intent)
552
+ # Parameter validation and the policy engine rebuild the plan from its
553
+ # actions alone; what the plan says about devices that took part without
554
+ # acting, and about refused proposals, is captured here so no later step
555
+ # can drop it. (included_without_action was lost from the audit entry
556
+ # whenever a policy modified the plan, from 2026-09-30 to 2026-10-02.)
557
+ _plan_idle = list(getattr(plan, "included_without_action", None) or [])
558
+ _plan_refused = list(getattr(plan, "refused_proposals", None) or [])
537
559
  if _M is not None:
538
560
  _M.intent_resolution_seconds.observe(time.perf_counter() - _t0)
539
561
 
@@ -781,6 +803,7 @@ class DoSyncHub:
781
803
  {"device_id": a.device_id, "action": a.action, "reason": err}
782
804
  for a, err in rejected_actions
783
805
  ],
806
+ refused_proposals=_plan_refused,
784
807
  operations=started_operations,
785
808
  )
786
809
  # Audit log
@@ -791,6 +814,8 @@ class DoSyncHub:
791
814
  "urgency": intent.urgency.value,
792
815
  "source": getattr(intent, "source", "api"),
793
816
  "actions": len(plan.actions),
817
+ **({"included_without_action": _plan_idle} if _plan_idle else {}),
818
+ **({"refused_proposals": _plan_refused} if _plan_refused else {}),
794
819
  # The chain answers "what did this system do". An action that never
795
820
  # left the hub is part of that answer and used not to be: entries
796
821
  # written before 2026-08-13 do not distinguish execution from
@@ -234,6 +234,26 @@ async def list_tools() -> list[types.Tool]:
234
234
  "type": "string",
235
235
  "description": "Message to include in notifications",
236
236
  },
237
+ "proposed_actions": {
238
+ "type": "array",
239
+ "items": {"type": "object", "properties": {
240
+ "device_id": {"type": "string"}, "action": {"type": "string"},
241
+ "params": {"type": "object"}}, "required": ["device_id", "action"]},
242
+ "description": (
243
+ "Optional, governed direct mode: the device actions you choose. "
244
+ "The hub runs only those that pass its rules -- the device "
245
+ "declares the action, the intent's class allows it, the device is "
246
+ "in a restricting place, no opposite actions, declared emergency "
247
+ "actions in an emergency -- and reports each refusal with its reason."),
248
+ },
249
+ "action_types": {
250
+ "type": "array", "items": {"type": "string"},
251
+ "description": (
252
+ "Optional: the subset of the class's actions you mean. "
253
+ "Required when the class asks for actions that undo each "
254
+ "other (e.g. lock and unlock): the hub refuses the intent "
255
+ "until you say which."),
256
+ },
237
257
  "location": {
238
258
  "type": "string",
239
259
  "description": (
@@ -482,6 +502,10 @@ async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
482
502
 
483
503
  # Merge the convenience fields (message, location) into context for backward
484
504
  # compatibility, without overwriting anything the AI put in `context`.
505
+ if arguments.get("proposed_actions") and "proposed_actions" not in ctx:
506
+ ctx["proposed_actions"] = list(arguments["proposed_actions"])
507
+ if arguments.get("action_types") and "action_types" not in ctx:
508
+ ctx["action_types"] = list(arguments["action_types"])
485
509
  for k, v in (("message", message), ("location", location)):
486
510
  if v and k not in ctx:
487
511
  ctx[k] = v
@@ -200,6 +200,52 @@ class ContextSignal:
200
200
  LOCATION_MAX_LENGTH = 256
201
201
 
202
202
 
203
+ #: Pairs of actuator types that undo each other. An emergency action list may
204
+ #: not contain both: that is exactly what the full-capability fallback did.
205
+ OPPOSITE_ACTIONS = (frozenset({"lock", "unlock"}), frozenset({"turn_on", "turn_off"}),
206
+ frozenset({"open", "close"}), frozenset({"start", "stop"}),
207
+ frozenset({"arm", "disarm"}))
208
+
209
+
210
+ def opposite_pairs(action_types) -> list:
211
+ """The pairs of OPPOSITE_ACTIONS that are both present in `action_types`."""
212
+ present = set(action_types)
213
+ return [tuple(sorted(p)) for p in OPPOSITE_ACTIONS if p <= present]
214
+
215
+
216
+ def normalize_emergency_actions(raw, actuator_types) -> list:
217
+ """Validate what a device does in an emergency (protocol 0.5, spec §6).
218
+
219
+ Accepts action names or {"action", "params"} objects. Each action must be
220
+ one the device declares, none may repeat, and no two may undo each other.
221
+ Raises ValueError with the reason; returns [{"action", "params"}, ...].
222
+ """
223
+ if raw is None:
224
+ return []
225
+ if not isinstance(raw, list):
226
+ raise ValueError("emergency_actions must be a list")
227
+ out, seen = [], set()
228
+ for item in raw:
229
+ entry = {"action": item, "params": {}} if isinstance(item, str) else dict(item or {})
230
+ action = entry.get("action")
231
+ if not isinstance(action, str) or not action:
232
+ raise ValueError(f"each emergency action needs an 'action' name: {item!r}")
233
+ if action not in actuator_types:
234
+ raise ValueError(f"'{action}' is not an action this device declares "
235
+ f"(it declares {sorted(actuator_types)})")
236
+ if action in seen:
237
+ raise ValueError(f"'{action}' is listed twice")
238
+ params = entry.get("params") or {}
239
+ if not isinstance(params, dict):
240
+ raise ValueError(f"params of '{action}' must be an object")
241
+ seen.add(action)
242
+ out.append({"action": action, "params": params})
243
+ for pair in OPPOSITE_ACTIONS:
244
+ if pair <= seen:
245
+ raise ValueError(f"emergency actions {sorted(pair)} undo each other; keep one")
246
+ return out
247
+
248
+
203
249
  def normalize_location(value) -> str:
204
250
  """Validate an operator-given location path and return it normalized.
205
251
 
@@ -315,6 +361,16 @@ class CapabilityManifest:
315
361
  #: It is where the device was ASSIGNED; the live position of something that
316
362
  #: moves is telemetry, not this.
317
363
  location: str = ""
364
+ #: What this device does when an emergency includes it and the intent's
365
+ #: class asks for none of its actions: a subset of its own actuators, with
366
+ #: optional params -- [{"action": "unlock"}] for an evacuation door,
367
+ #: [{"action": "stop"}] for a conveyor. Until protocol 0.5 rev. 2026-09-30
368
+ #: such a device executed EVERY actuator it declared, so a lock received
369
+ #: lock and unlock at once. What a device must do in an emergency depends on
370
+ #: the deployment, so a value the operator set takes precedence over the
371
+ #: device's own (provenance["emergency_actions_set_by"] == "operator").
372
+ #: Empty: the device takes part without acting, and the plan says so.
373
+ emergency_actions: list = field(default_factory=list)
318
374
 
319
375
  def to_dict(self) -> dict:
320
376
  d = {
@@ -363,6 +419,8 @@ class CapabilityManifest:
363
419
  d["provenance"] = self.provenance
364
420
  if self.location:
365
421
  d["location"] = self.location
422
+ if self.emergency_actions:
423
+ d["emergency_actions"] = [dict(a) for a in self.emergency_actions]
366
424
  return d
367
425
 
368
426
  def carry_over_from(self, existing: "CapabilityManifest") -> None:
@@ -377,6 +435,11 @@ class CapabilityManifest:
377
435
  Descriptive data only: nothing here decides whether an action may run.
378
436
  """
379
437
  self.location = existing.location
438
+ if (existing.provenance or {}).get("emergency_actions_set_by") == "operator":
439
+ # The operator decided what this device does in an emergency; a
440
+ # device re-registering does not get to change that.
441
+ self.emergency_actions = [dict(a) for a in existing.emergency_actions]
442
+ self.provenance = {**(self.provenance or {}), "emergency_actions_set_by": "operator"}
380
443
  if (not self.adapter_config and existing.adapter_config
381
444
  and (self.adapter or None) == (existing.adapter or None)):
382
445
  self.adapter_config = dict(existing.adapter_config)
@@ -455,6 +518,7 @@ class CapabilityManifest:
455
518
  discovery_evidence=dict(d.get("discovery_evidence") or {}),
456
519
  provenance=dict(d.get("provenance") or {}),
457
520
  location=d.get("location", ""),
521
+ emergency_actions=[dict(a) for a in (d.get("emergency_actions") or [])],
458
522
  )
459
523
 
460
524
  def to_public_dict(self) -> dict:
@@ -652,6 +716,14 @@ class ActionPlan:
652
716
  created_at: float = field(default_factory=time.time)
653
717
  failure_policy: "FailurePolicy" = field(default=None)
654
718
  max_retries: int = 1
719
+ #: Emergency-capable devices an emergency included that had no action of the
720
+ #: class and declared no emergency_actions: they take part without acting,
721
+ #: and the record says so rather than hiding them.
722
+ included_without_action: list = field(default_factory=list)
723
+ #: Governed direct mode (spec §6, rule 7): the actions an agent proposed
724
+ #: that the hub refused, each {device_id, action, reason}. A refused
725
+ #: proposal is never executed and never silently dropped.
726
+ refused_proposals: list = field(default_factory=list)
655
727
  # failure_policy=None → usa CONTINUE (backward compatible)
656
728
 
657
729
  @dataclass
@@ -709,6 +781,7 @@ class IntentResult:
709
781
  failure_policy_applied: str = "continue"
710
782
  status: str = "success"
711
783
  rejected_actions: list = field(default_factory=list)
784
+ refused_proposals: list = field(default_factory=list)
712
785
  # Long-running operations started by this intent (execution_model). Each entry:
713
786
  # {operation_id, device_id, state}. EMPTY for intents that only triggered instant
714
787
  # actions → a client that predates operations sees an identical IntentResult.