docs/adr/ADR-127-homecore-state-machine-rust.md
| Field | Value |
|---|---|
| Status | Proposed |
| Date | 2026-05-25 |
| Deciders | ruv |
| Codename | HOMECORE-CORE |
| Relates to | ADR-126 (HOMECORE master), ADR-028 (witness chain), ADR-124 (RUVIEW-POLICY) |
| Tracking issue | TBD |
homeassistant/core.py is the 3,200-line heart of Python Home Assistant. It defines five objects that every other HA component depends on:
HomeAssistant — the runtime coordinator, event loop holder, and service locator. Contains bus (EventBus), states (StateMachine), services (ServiceRegistry), config (Config), components (loaded component set).EventBus — publish/subscribe event dispatch. async_fire(event_type, event_data) dispatches to all registered listeners. Listener registration is async_listen(event_type, callback). Wildcard listener is MATCH_ALL. Event data is a plain Python dict.StateMachine — an in-memory dictionary from entity_id (str) to State. async_set(entity_id, new_state, attributes) writes and fires state_changed. get(entity_id) reads. async_remove(entity_id) fires state_removed. States are immutable snapshots with last_changed, last_updated, context.ServiceRegistry — maps (domain, service_name) → async handler function. async_call(domain, service, data) fires a call_service event, waits for the registered handler. async_register(domain, service, handler, schema) registers a handler with optional voluptuous schema validation.EntityRegistry (homeassistant/helpers/entity_registry.py) — persists metadata (enabled/disabled, name override, area assignment, device ID, unique ID, entity category) across restarts. Stored in .storage/core.entity_registry. Loaded at startup; written on every change.The DeviceRegistry (homeassistant/helpers/device_registry.py, stored in .storage/core.device_registry) tracks physical devices that entities belong to. Entities link to devices via device_id; devices link to config entries via config_entry_id.
Python HA's core.py is a single-process Python 3.12 module that:
asyncio.LockFor HOMECORE the same semantic requirements apply, but the implementation must support:
Implement the homecore Rust crate at v2/crates/homecore/ with the following design.
DashMap + Tokio broadcastThe primary state store is a DashMap<EntityId, Arc<State>> where:
EntityId is a validated newtype around String (validated format: domain.name)State is a frozen struct: entity_id, state (String), attributes (serde_json::Value), last_changed (DateTime<Utc>), last_updated (DateTime<Utc>), context (Context)Arc<State> allows zero-copy cloning for readers while the writer atomically replaces the map entryState changes are published to a tokio::sync::broadcast::Sender<StateChangedEvent> channel (capacity: 4,096 events). Any number of receivers subscribe — the recorder, automation engine, WebSocket subscriber handler, and ruvector dual-write task all hold independent receivers. Slow receivers that fall behind by 4,096 events receive a RecvError::Lagged and must re-sync from the current state map.
HOMECORE distinguishes two event categories:
StateChanged, ServiceCall, ComponentLoaded, PlatformDiscovered, HomeAssistantStart, HomeAssistantStop. These use Tokio typed broadcast channels with zero allocation on the read path.event_type: String, event_data: serde_json::Value). These use a single broadcast::Sender<DomainEvent> where DomainEvent carries the type string and data blob. This mirrors HA's EventBus.async_fire().HashMap + mpsc dispatchServices are registered as (Domain, ServiceName) → ServiceHandler where ServiceHandler is a Box<dyn Fn(ServiceCall) -> BoxFuture<ServiceResponse> + Send + Sync>. The registry lives in a tokio::sync::RwLock<HashMap<(Domain, ServiceName), ServiceHandler>>. Service calls go through the event bus (fire call_service) and are dispatched to the handler by an internal router task. This matches HA's indirection: hass.services.async_call(domain, service, data) does not call the handler directly; it fires an event.
The entity registry is a RwLock<HashMap<EntityId, EntityEntry>> backed by an async JSON writer that flushes to .homecore/storage/core.entity_registry on every write. The schema matches HA's core.entity_registry schema (version 13 as of HA 2025.1) so ADR-134 migration can read both formats interchangeably.
EntityEntry fields mirrored from HA:
entity_id: EntityIdunique_id: Option<String>platform: Stringname: Option<String> (user override)disabled_by: Option<DisabledBy> (user, integration, config_entry)area_id: Option<AreaId>device_id: Option<DeviceId>entity_category: Option<EntityCategory> (config, diagnostic)config_entry_id: Option<ConfigEntryId>DeviceRegistry mirrors HA's core.device_registry schema (version 13). Devices are identified by a set of (id_type, id_value) tuples (the identifiers field), which matches HA's pattern of accepting multiple identifier types per device (MAC address, serial number, integration-specific ID).
DeviceEntry and the in-memory DeviceRegistry are implemented. On server
startup, entity and device registry files are restored in deterministic key
order with a configurable hard row bound; malformed individual entries are
isolated and reported.
| HA module / file | What it does | HOMECORE preserves | Changes | Drops |
|---|---|---|---|---|
homeassistant/core.py StateMachine | In-memory state store, fire state_changed | Same semantics: immutable snapshots, last_changed, last_updated, context | DashMap instead of asyncio-locked dict; broadcast::Sender instead of asyncio callbacks | Python asyncio coupling |
homeassistant/core.py EventBus | Pub/sub event dispatch | MATCH_ALL listener; per-type listener; event data dict | Typed system events + untyped domain events; no Python dict — use serde_json::Value | @callback decorator, HassJob abstraction |
homeassistant/core.py ServiceRegistry | Register/call services | Same (domain, service) key structure; schema validation | Schema validation via serde Deserialize trait instead of voluptuous | voluptuous, Python type coercions |
homeassistant/core.py HomeAssistant | Runtime coordinator / service locator | State machine + event bus + services accessible on one struct | Struct with Arc<HomeCoreInner> for cheap cloning across tasks | asyncio event loop holder, Python executor |
homeassistant/helpers/entity_registry.py | Persist entity metadata | All fields listed in §2.4; file format compatible | Async tokio I/O; no Python pickle | Python-specific persistence helpers |
homeassistant/helpers/device_registry.py | Persist device metadata | identifiers, connections, manufacturer, model, name, via_device_id | Async tokio I/O | — |
homeassistant/helpers/entity.py | Entity base class | entity_id, state, attributes, unique_id, device_info, async_write_ha_state semantics | Trait HomeCoreEntity instead of class | Python MRO, @property decorators |
homeassistant/helpers/event.py | Convenience event helpers | async_track_state_change, async_track_time_interval (as Rust timer tasks) | Rust closures / async tasks | Python asyncio task wrappers |
| HA Python surface | HOMECORE Rust equivalent |
|---|---|
hass.states.get(entity_id) | hass.states.get(&entity_id) -> Option<Arc<State>> |
hass.states.async_set(entity_id, state, attributes) | hass.states.set(entity_id, state, attributes).await |
hass.states.async_remove(entity_id) | hass.states.remove(&entity_id).await |
hass.states.async_all(domain_filter) | hass.states.all(domain_filter) -> Vec<Arc<State>> |
hass.bus.async_fire(event_type, data) | hass.bus.fire(event_type, data).await |
hass.bus.async_listen(event_type, callback) | hass.bus.subscribe(event_type) -> broadcast::Receiver<DomainEvent> |
hass.services.async_call(domain, service, data) | hass.services.call(domain, service, data).await -> ServiceResponse |
hass.services.async_register(domain, service, handler, schema) | hass.services.register(domain, service, handler) |
hass.services.has_service(domain, service) | hass.services.has(domain, service) -> bool |
entity_registry.async_get(entity_id) | entity_registry.get(&entity_id) -> Option<&EntityEntry> |
entity_registry.async_update_entity(entity_id, **kwargs) | entity_registry.update(entity_id, patch).await |
device_registry.async_get_device(identifiers) | device_registry.get_by_identifiers(identifiers) -> Option<&DeviceEntry> |
Context(user_id, parent_id) | Context { id: Uuid, parent_id: Option<Uuid>, user_id: Option<UserId> } |
v2/crates/homecore/ workspace member with Cargo.toml.State, EntityId, Domain, ServiceName, Context, DomainEvent types.StateMachine: DashMap + broadcast channel; set(), get(), remove(), all().EventBus: typed broadcast for system events + untyped broadcast for domain events.ServiceRegistry: RwLock<HashMap> + mpsc dispatch task.EntityRegistry: in-memory + JSON async writer to .homecore/storage/core.entity_registry.DeviceRegistry: in-memory + JSON async writer to .homecore/storage/core.device_registry.serde with #[serde(rename_all = "snake_case")]; schema version 13 header written to match HA format.HomeCoreEntity trait: entity_id(), unique_id(), name(), device_info(), state(), attributes(), async_write_ha_state(&hass).Platform trait: async_setup_entry(hass, config_entry) -> Result<()>.ConfigEntry struct mirroring HA's ConfigEntry fields.RecvError::Lagged handling.| Risk | Likelihood | Severity | Mitigation | Cross-ADR impact |
|---|---|---|---|---|
| Broadcast channel lag — a slow subscriber (e.g. ruvector recorder write) lags behind and drops events | Medium | High | Give recorder its own channel separate from WS subscribers; recorder is the hot path, give it highest priority | ADR-132: recorder write path must be designed to keep up with 100 Hz state writes |
| DashMap contention — shard count default (16) may be too low for 100 Hz writes on a single entity | Low | Medium | Increase DashMap shard count to 64; benchmark before ADR-130 integration | ADR-130: REST API reads state directly from DashMap — must be lock-free |
Entity registry format drift — HA updates .storage/core.entity_registry schema; HOMECORE falls behind | Medium | Medium | Pin to schema version 13; version-check on load; fail loudly on unknown version | ADR-134: migration tool reads HA entity registry — must support the same schema version |
Context propagation — HA's Context is used for audit trails (which automation triggered which service call). HOMECORE must propagate it correctly or automation audits break | High | Low | Derive Context from source event at every service call; thread through ServiceCall.context field | ADR-129: automation engine must supply context when calling services |
Q1: Should EntityId validation be strict (reject anything that doesn't match [a-z0-9_]+\.[a-z0-9_]+) or lenient (accept any UTF-8 string)? HA itself accepts unicode entity IDs since 2024.3. Strict validation simplifies routing; lenient matches HA's actual behaviour.
Q2: The broadcast::Sender capacity of 4,096 is chosen based on a worst-case of 100 state writes/s × 40 s of acceptable lag before a slow receiver is declared dead. Is 40 s the right threshold, or should it be configurable per receiver?
Q3: Should the HomeCoreEntity trait be object-safe (enabling Vec<Box<dyn HomeCoreEntity>>) or use associated types (enabling monomorphisation)? Object safety is required for the WASM plugin boundary (ADR-128); monomorphisation is faster for built-in integrations.
Q4: HA's State.context carries a user_id that traces which user or automation initiated a state change. HOMECORE uses UserId from the auth layer (ADR-130). Is the auth layer a dependency of the core state machine, or should user_id be an optional opaque string to avoid circular deps?
homeassistant/core.py — HomeAssistant, StateMachine (lines 1–800), EventBus (lines 800–1100), ServiceRegistry (lines 1100–1500), Config (lines 1500–2000)homeassistant/helpers/entity_registry.py — EntityRegistry, RegistryEntry (all ~1,900 lines); schema version constant STORAGE_VERSIONhomeassistant/helpers/device_registry.py — DeviceRegistry, DeviceEntry; schema versionhomeassistant/helpers/entity.py — Entity base class; async_write_ha_state; entity lifecycle hookshomeassistant/helpers/event.py — async_track_state_change, async_track_time_intervalv2/crates/wifi-densepose-sensing-server/src/main.rs — Axum + Tokio architecture pattern used throughout the existing server stackdocs/adr/ADR-126-ruview-native-ha-port-master.md — HOMECORE master; §5.5 crate naming; §6 compatibility contract; §5.1 RUVIEW-POLICYFoundational review of the homecore crate — the state store + event bus +
service/entity registries every other HOMECORE module trusts. Same rigor as
the ADR-129/130/132/133/161 sibling reviews. Three real fixes (one
concurrency, two hardening), each pinned by a fails-on-old test; the bus-lag
and lock-discipline dimensions confirmed clean with evidence.
state_changed, the
crux). FIXED. StateMachine::set did get() (releasing the DashMap
shard lock) → compute the next snapshot + the no-op / last_changed
decision → insert() (re-acquiring the lock) → send(). The
read-modify-write was not atomic w.r.t. a concurrent writer on the
same entity, contradicting §2.1's promise that "the writer atomically
replaces the map entry." A writer that read a stale old could
mis-classify a genuine transition as a no-op and drop its
state_changed event (a missed automation trigger) or fire an event
whose new_state duplicated the previously delivered one (a spurious
trigger for any automation keyed on old_state != new_state). Fix:
hold the shard write-lock across the entire read→decide→insert→fire
sequence via entry()/insert_entry(); tx.send is non-blocking,
non-async, and never re-enters the map, so firing under the shard lock
cannot deadlock and keeps global event order in lock-step with global
commit order. Pinned by concurrent_set_fires_no_duplicate_adjacent_events
(4 writers toggling one entity A/B; asserts no two consecutive fired
events carry an identical new_state — impossible under correct
serialisation; a probe observed ~93k such duplicate-adjacent events across
200 trials on the racy code, zero on the fix).entity_id — memory-DoS at the REST boundary).
FIXED. homecore-api/src/rest.rs parses untrusted path segments
straight through EntityId::parse; with no length cap, an
otherwise-valid id (a. + many MB of [a-z0-9_]) was accepted and a
POST /api/states/<giant> would persist it into the DashMap state store
(permanent growth across distinct ids). Fix: reject ids longer than
MAX_ENTITY_ID_LEN (255, HA-compatible) up front in parse(), before any
per-char scan, with a new EntityIdError::TooLong; fail-closed at the
boundary type protects every caller. Pinned by entity_id_length_boundary
(exactly-MAX accepted, MAX+1 and a 4 MiB id rejected — fails on old code).ServiceRegistry::call already ran handlers outside the registry lock (no
RwLock poisoning, no blocking of other callers — clean), but a
panicking handler unwound through call() into the caller's task. Fix:
wrap the handler future in AssertUnwindSafe + catch_unwind, converting
a panic to ServiceError::HandlerPanicked; the registry stays fully
usable. Pinned by panicking_handler_is_isolated_and_registry_survives.Dimensions confirmed clean (with evidence):
StateMachine and EventBus use bounded tokio::sync::broadcast
(capacity 4,096). A slow subscriber gets a recoverable Lagged(n)
(drop-oldest + re-sync); fire_* is non-blocking and never waits on
slow receivers, so a lagging subscriber cannot block the publisher, grow
the channel without bound, or take down a fast subscriber. Evidenced by
slow_subscriber_does_not_block_publisher_or_kill_the_bus (fire 3×
capacity at an idle subscriber; publisher unblocked, bus stays live).{state DashMap, registry RwLock, service RwLock} simultaneously, so
no inconsistent-ordering deadlock can exist. Every tokio::sync::RwLock
guard in registry.rs/service.rs is used in a single synchronous
statement and dropped before any .await; call explicitly scopes the
read guard out before awaiting the handler. The only guard held across a
send is the DashMap shard lock in set, across a synchronous
(non-await) broadcast send — safe.unwrap/expect/index in non-test code
beyond the safe send().unwrap_or(0) and the dead-but-harmless
split_once(...).unwrap_or(...) fallbacks on already-validated ids.cargo test -p homecore --no-default-features: 20 → 24 passed, 0 failed
(+4 pins). Workspace green; Python deterministic proof unchanged
(f8e76f21…46f7a, bit-exact — homecore is off the signal proof path).
docs/adr/ADR-028-esp32-capability-audit.md — witness chain pattern (Ed25519 per state transition)