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.
- {dosync-0.7.0 → dosync-0.8.0}/PKG-INFO +4 -4
- {dosync-0.7.0 → dosync-0.8.0}/README.md +3 -3
- {dosync-0.7.0 → dosync-0.8.0}/dosync/__init__.py +1 -1
- {dosync-0.7.0 → dosync-0.8.0}/dosync/certify.py +90 -7
- {dosync-0.7.0 → dosync-0.8.0}/dosync/declarative.py +12 -2
- {dosync-0.7.0 → dosync-0.8.0}/dosync/hub.py +26 -1
- {dosync-0.7.0 → dosync-0.8.0}/dosync/mcp_server.py +24 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/models.py +73 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/resolvers.py +137 -15
- {dosync-0.7.0 → dosync-0.8.0}/dosync/server.py +104 -7
- {dosync-0.7.0 → dosync-0.8.0}/dosync.egg-info/PKG-INFO +4 -4
- {dosync-0.7.0 → dosync-0.8.0}/dosync.egg-info/SOURCES.txt +8 -0
- dosync-0.8.0/tests/test_agent_lab_matches_the_hub.py +130 -0
- dosync-0.8.0/tests/test_capability_decides_participation.py +60 -0
- dosync-0.8.0/tests/test_emergency_actions.py +111 -0
- dosync-0.8.0/tests/test_evaluation_tools_run.py +44 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_explain_consistency.py +6 -1
- dosync-0.8.0/tests/test_governance_vocabulary.py +33 -0
- dosync-0.8.0/tests/test_governed_direct_mode.py +144 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_measurement_tools_are_honest.py +13 -2
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_no_operator_data.py +3 -0
- dosync-0.8.0/tests/test_opposite_actions_and_emergency_precedence.py +95 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_resolution_wiring.py +8 -2
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_resolver_semantics.py +9 -5
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_schemas_match_the_hub.py +1 -0
- dosync-0.8.0/tests/test_thing_description_export.py +55 -0
- {dosync-0.7.0 → dosync-0.8.0}/LICENSE +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/adapter_drafting.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/adapters/__init__.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/adapters/ble.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/adapters/declarative.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/adapters/homeassistant.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/adapters/matter.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/adapters/mavlink.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/adapters/mqtt.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/adapters/notifications.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/adapters/shelly.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/adapters/wiz.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/audit.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/audit_backup.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/auth.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/auth_fastapi.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/cert_signing.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/cli.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/composite_executor.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/composite_operations.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/config_reference.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/dashboard.html +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/db.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/device_arbiter.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/discoverers.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/discoverers_mdns.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/discoverers_ssdp.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/discovery.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/ed25519_pure.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/examples/__init__.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/examples/declarative/3d-printer.yaml +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/examples/declarative/air-conditioner.yaml +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/examples/declarative/building-lighting.json +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/examples/declarative/industrial-conveyor.yaml +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/examples/declarative/light-generic.yaml +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/examples/declarative/television.yaml +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/execution.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/executor.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/geo.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/hub_monitor.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/lightweight.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/manage.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/metrics.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/occupancy.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/operation_guards.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/operation_supervisor.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/operations.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/paths.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/plan_executor.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/plugins.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/policies.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/policy_config.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/py.typed +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/reconciler.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/registry.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/restore.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/route_composer.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/security.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/spec/TAG-VOCABULARY.md +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/spec_coverage.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/state_refresh.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/telemetry.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/templates/adapter-draft-prompt.md +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync/validation.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync.egg-info/dependency_links.txt +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync.egg-info/entry_points.txt +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync.egg-info/requires.txt +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/dosync.egg-info/top_level.txt +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/pyproject.toml +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/setup.cfg +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_adapter_drafting.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_adapters.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_adapters_speak_the_protocol.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_audit_archive.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_audit_backup.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_audit_chain_integrity.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_audit_chain_ordering.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_audit_concurrency.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_audit_endpoint_limit.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_audit_hashless.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_audit_provenance.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_auth.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_ble_adapter.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_certification_guide.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_certification_honesty.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_check_helpers_can_fail.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_claim_state_machine.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_collaborators_read_the_live_hub.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_composite_executor.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_composite_operations.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_composite_orchestration.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_composition_kind_db.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_composition_kind_endpoint.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_composition_routing.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_dashboard_tells_the_truth.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_dashboard_uses_live_endpoints.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_db.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_db_write_serialization.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_declarative_adapters.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_declarative_location.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_declarative_quarantine.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_deployment_env_contract.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_deployment_layout.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_device_health.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_device_heartbeat.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_device_identity.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_device_location.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_direct_action_governance.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_direct_action_instrumented.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_discovery_adoption.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_documentation_is_navigable.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_drone_policies.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_ed25519_pure.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_emergency_preemption.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_env_loading_is_explicit.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_evaluation_metrics.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_event_loop_migration.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_explain_resolve_parity.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_extraction_preserves_behaviour.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_geo.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_ha_bridge_hygiene.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_ha_read_sensors.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_health_recorded_once.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_hub_monitor.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_idempotency.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_independent_observation.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_integration_suite.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_intent_rejections_visible.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_lightweight_heartbeat.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_location_restricts.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_mavlink_adapter.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_mavlink_channels.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_mavlink_listener.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_mavlink_return_home.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_mavlink_single_reader.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_mavlink_telemetry_closure.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_mcp_dynamic_intents.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_mcp_http_transport.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_mcp_partial_progress.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_mcp_scenarios_are_live.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_mcp_speaks_english.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_mcp_status.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_metrics.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_models.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_multihub_endpoints.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_no_undefined_names.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_notification_templates.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_occupancy.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_openapi_contract.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_operation_guards.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_operation_supervisor.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_operations.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_operations_endpoints.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_operations_persistence.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_operations_wiring.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_panel_polish_2026_07_21.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_plan_executor.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_policies.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_policy_config.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_protocol_version.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_public_claims.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_pushed_verification.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_quickstart_is_runnable.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_reachability_cause.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_recall_benchmark_postpolicy.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_reconciler.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_registry.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_resolver_scoring.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_route_composer.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_sensor_kind.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_server.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_server_enforces_auth.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_simulation_is_declared.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_state_refresh.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_tag_vocabulary.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_telemetry.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_telemetry_bridge.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_third_party_adapters.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_transport_discoverers.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_undefined_name_regressions.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_universal_intent_contract.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_unknown_location_refused.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_validation.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_validation_integration.py +0 -0
- {dosync-0.7.0 → dosync-0.8.0}/tests/test_wiring_audit.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 —
|
|
@@ -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 (
|
|
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":
|
|
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
|
-
|
|
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:
|
|
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-
|
|
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
|
|
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
|
-
|
|
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.
|