Back to Ruview

ADR-127: HOMECORE-CORE — Rust state machine, entity registry, event bus, service registry

docs/adr/ADR-127-homecore-state-machine-rust.md

209620.0 KB
Original Source

ADR-127: HOMECORE-CORE — Rust state machine, entity registry, event bus, service registry

FieldValue
StatusProposed
Date2026-05-25
Decidersruv
CodenameHOMECORE-CORE
Relates toADR-126 (HOMECORE master), ADR-028 (witness chain), ADR-124 (RUVIEW-POLICY)
Tracking issueTBD

1. Context

homeassistant/core.py is the 3,200-line heart of Python Home Assistant. It defines five objects that every other HA component depends on:

  1. HomeAssistant — the runtime coordinator, event loop holder, and service locator. Contains bus (EventBus), states (StateMachine), services (ServiceRegistry), config (Config), components (loaded component set).
  2. 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.
  3. 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.
  4. 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.
  5. 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.

1.1 Why these specific files matter

Python HA's core.py is a single-process Python 3.12 module that:

  • Holds the asyncio event loop directly
  • Serialises all state-changed writes through asyncio.Lock
  • Fires event listeners in the same event loop iteration that fired the event (listeners cannot block)
  • Is single-threaded by design — concurrent writes to the state machine are impossible without explicit async primitives

For HOMECORE the same semantic requirements apply, but the implementation must support:

  • Concurrent reads from dozens of integration WASM sandboxes polling current state
  • High-frequency writes from the RuView sensing stack (CSI at 100 Hz; state updates at up to 20 Hz per entity)
  • Ordered delivery of state_changed events to automation triggers (ADR-129) and recorder (ADR-132) subscribers
  • Zero-copy reads where possible for the REST API (ADR-130) path

2. Decision

Implement the homecore Rust crate at v2/crates/homecore/ with the following design.

2.1 State machine: DashMap + Tokio broadcast

The 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 entry

State 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.

2.2 Event bus: typed + untyped channels

HOMECORE distinguishes two event categories:

  1. System events (typed): StateChanged, ServiceCall, ComponentLoaded, PlatformDiscovered, HomeAssistantStart, HomeAssistantStop. These use Tokio typed broadcast channels with zero allocation on the read path.
  2. Integration events (untyped): integrations fire arbitrary event types (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().

2.3 Service registry: HashMap + mpsc dispatch

Services 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.

2.4 Entity registry: persisted metadata sidecar

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: EntityId
  • unique_id: Option<String>
  • platform: String
  • name: 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>

2.5 Device registry: parallel sidecar

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.


3. HA-side reference table

HA module / fileWhat it doesHOMECORE preservesChangesDrops
homeassistant/core.py StateMachineIn-memory state store, fire state_changedSame semantics: immutable snapshots, last_changed, last_updated, contextDashMap instead of asyncio-locked dict; broadcast::Sender instead of asyncio callbacksPython asyncio coupling
homeassistant/core.py EventBusPub/sub event dispatchMATCH_ALL listener; per-type listener; event data dictTyped system events + untyped domain events; no Python dict — use serde_json::Value@callback decorator, HassJob abstraction
homeassistant/core.py ServiceRegistryRegister/call servicesSame (domain, service) key structure; schema validationSchema validation via serde Deserialize trait instead of voluptuousvoluptuous, Python type coercions
homeassistant/core.py HomeAssistantRuntime coordinator / service locatorState machine + event bus + services accessible on one structStruct with Arc<HomeCoreInner> for cheap cloning across tasksasyncio event loop holder, Python executor
homeassistant/helpers/entity_registry.pyPersist entity metadataAll fields listed in §2.4; file format compatibleAsync tokio I/O; no Python picklePython-specific persistence helpers
homeassistant/helpers/device_registry.pyPersist device metadataidentifiers, connections, manufacturer, model, name, via_device_idAsync tokio I/O
homeassistant/helpers/entity.pyEntity base classentity_id, state, attributes, unique_id, device_info, async_write_ha_state semanticsTrait HomeCoreEntity instead of classPython MRO, @property decorators
homeassistant/helpers/event.pyConvenience event helpersasync_track_state_change, async_track_time_interval (as Rust timer tasks)Rust closures / async tasksPython asyncio task wrappers

4. Public API parity table

HA Python surfaceHOMECORE 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> }

5. Phased implementation plan

P1 — Skeleton (2 weeks)

  • Create v2/crates/homecore/ workspace member with Cargo.toml.
  • Define 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.
  • Unit tests: 50 state writes/reads with concurrent readers; verify broadcast delivery.

P2 — Service registry + entity registry (2 weeks)

  • 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.
  • Serialization: serde with #[serde(rename_all = "snake_case")]; schema version 13 header written to match HA format.
  • Unit tests: register service, call service, verify handler invoked; persist and reload entity registry.

P3 — Trait surface for integrations (1 week)

  • 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.
  • Integration test: a minimal test integration registers an entity, writes a state, reads it back from the state machine.

P4 — Performance validation (1 week)

  • Benchmark: 1,000 state writes/s on Pi 5; measure latency at p50/p95/p99.
  • Benchmark: 100 concurrent WS subscribers each receiving all state_changed events; measure delivery lag.
  • Benchmark: broadcast channel saturation test at 4,096 capacity; verify RecvError::Lagged handling.
  • Acceptance criterion: p99 state write latency < 1 ms on Pi 5 (8 GB, 4 cores).

6. Risks

RiskLikelihoodSeverityMitigationCross-ADR impact
Broadcast channel lag — a slow subscriber (e.g. ruvector recorder write) lags behind and drops eventsMediumHighGive recorder its own channel separate from WS subscribers; recorder is the hot path, give it highest priorityADR-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 entityLowMediumIncrease DashMap shard count to 64; benchmark before ADR-130 integrationADR-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 behindMediumMediumPin to schema version 13; version-check on load; fail loudly on unknown versionADR-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 breakHighLowDerive Context from source event at every service call; thread through ServiceCall.context fieldADR-129: automation engine must supply context when calling services

7. Open questions

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?


8. References

HA upstream

  • homeassistant/core.pyHomeAssistant, StateMachine (lines 1–800), EventBus (lines 800–1100), ServiceRegistry (lines 1100–1500), Config (lines 1500–2000)
  • homeassistant/helpers/entity_registry.pyEntityRegistry, RegistryEntry (all ~1,900 lines); schema version constant STORAGE_VERSION
  • homeassistant/helpers/device_registry.pyDeviceRegistry, DeviceEntry; schema version
  • homeassistant/helpers/entity.pyEntity base class; async_write_ha_state; entity lifecycle hooks
  • homeassistant/helpers/event.pyasync_track_state_change, async_track_time_interval

This repo

  • v2/crates/wifi-densepose-sensing-server/src/main.rs — Axum + Tokio architecture pattern used throughout the existing server stack
  • docs/adr/ADR-126-ruview-native-ha-port-master.md — HOMECORE master; §5.5 crate naming; §6 compatibility contract; §5.1 RUVIEW-POLICY

9. Security & concurrency review (P1 core, beyond-SOTA sweep)

Foundational 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.

  • HC-RACE-01 (state-set TOCTOU — lost / reordered 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).
  • HC-EID-LEN-01 (unbounded 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).
  • HC-SVC-PANIC-01 (service-handler panic not isolated). HARDENED. 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):

  • Event-bus bounds / lag (same class as the homecore-api WS lag-DoS). Both 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).
  • Lock ordering / lock-across-await (deadlock). No code path holds two of {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.
  • Panic-on-input. No reachable 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)