diff --git a/sandbox/ARCHITECTURE.md b/sandbox/ARCHITECTURE.md index 25c2fceffc17..30cf7559cdbc 100644 --- a/sandbox/ARCHITECTURE.md +++ b/sandbox/ARCHITECTURE.md @@ -65,7 +65,7 @@ make the call. First match wins: 1. `integration_type == "system"` → **main** (part of the HA runtime; sandboxing is meaningless). 2. `domain ∈ ALWAYS_MAIN` → **main** (hand-picked deny-list, each with an inline "why"). -3. Any platform in `SANDBOX_INCOMPATIBLE_PLATFORMS` → **main** (`stt`, `tts`, `conversation`, `assist_satellite`, `wake_word`, `camera` — audio/byte streams the channel can't ferry). +3. Any platform in `SANDBOX_INCOMPATIBLE_PLATFORMS` → **main** (`stt`, `tts`, `conversation`, `assist_satellite`, `wake_word`, `camera` — audio/byte streams the channel can't ferry; plus `todo`, whose To-do panel reads the sync `todo_items` property that also feeds `state`, so it needs a pushed item-list cache the bridge doesn't have yet — see [`docs/query-shaped-rpcs.md`](docs/query-shaped-rpcs.md)). 4. Custom (non-built-in) integration → group **`custom`**. 5. Otherwise → group **`built-in`**. @@ -215,7 +215,7 @@ re-send it and main refreshes the existing proxy in place (no duplicate). Proxy `unique_id`s are prefixed with the source domain (`:`) so two integrations in one group can't collide. -On main, `SandboxBridge` instantiates a domain-typed proxy (all **32** domains +On main, `SandboxBridge` instantiates a domain-typed proxy (all **31** domains have one under `entity/`) and attaches it via the `EntityComponent.async_register_remote_platform` core hook. Each outbound proxy call sends one RPC; coalescing same-tick calls into a single multi-entity RPC @@ -354,7 +354,7 @@ waits on the websocket transport). See - **State-sharing opt-in consumer** + main-side filtering ([`docs/design-share-states.md`](docs/design-share-states.md)); would let the lockdown helpers (§3) return to sandboxes. - **Cross-sandbox in-process dependencies** — ESPHome serial / BLE proxy, and IR/RF command flows, where one integration depends on another's in-process surface across a sandbox boundary. - **`Context` group attribute** (§10) — a core `Context` field naming which sandbox group originated an action, a richer audit answer than today's `user_id=None`. Context restoration from seen ids, dropping the unused token, and removing the per-group system user all **shipped** (`plans/plan-auth-context.md`); the wire still carries `context_id` only, so the sandbox can never fabricate attribution. -- **Query-shaped RPCs** for `calendar` / `todo` / `weather` server-side queries. +- **Query-shaped RPCs** for the server-side query / subscribe / WS-only-mutation entity APIs the fire-and-forget bridge can't express — `calendar` (listings, event subscribe/update/delete), `todo` (item list/subscribe/move), `weather` (forecasts), `media_player` (browse/search), `update` (release notes), `vacuum` (segments). They currently raise `HomeAssistantError` instead of silently returning empty. Full catalogue + the two missing primitives (request/response + subscription) in [`docs/query-shaped-rpcs.md`](docs/query-shaped-rpcs.md). - **pip/egress validation** for custom-integration dependencies in the container (§7). --- diff --git a/sandbox/CLAUDE.md b/sandbox/CLAUDE.md index 545b02b73762..a273de2b51ce 100644 --- a/sandbox/CLAUDE.md +++ b/sandbox/CLAUDE.md @@ -154,11 +154,18 @@ took the codebase from Phase 11 to Phase 17. What's still open: --snapshot-update` per integration) and ~70 `created_at` snapshot drifts (fix is integration-side freezegun, or an optional Phase 17b clock-pinning fixture on the compat plugin — ~30 LOC). -- **`calendar` / `todo` / `weather` query-shaped RPCs.** The Phase - 13 proxies return empty lists for `async_get_events`, `todo_items`, - and `weather.async_forecast_*` because the action-call channel - can't express server-side queries. Add a query-shaped RPC if the - compat sweep ever surfaces an integration that needs them. +- **Query-shaped RPCs (calendar / weather / media_player / update / + vacuum).** The fire-and-forget action-call channel can't express + server-side queries, subscriptions, or WS-only mutations, so these + proxy APIs now **raise `HomeAssistantError`** (via + `entity.raise_not_proxied`) instead of silently returning empty. + `todo` is a special case: its To-do panel reads the sync `todo_items` + property (which feeds `state`), so it can't be a query at all — it's in + `SANDBOX_INCOMPATIBLE_PLATFORMS` (routed to main, no proxy) until a + push primitive lands. Full catalogue, interim behaviour, and the two + missing primitives (request/response + subscription RPC) in + [`docs/query-shaped-rpcs.md`](docs/query-shaped-rpcs.md). Planned + design: [`plans/plan-query-rpc.md`](plans/plan-query-rpc.md). - **Non-idempotent service handlers** (`ai_task`, `image`). `ALWAYS_MAIN` punt for the sandbox; a future spec on service-handler-level interception or sandbox-aware integration hooks is the long-term diff --git a/sandbox/OVERVIEW.md b/sandbox/OVERVIEW.md index 00523d416bae..d0c57bad9141 100644 --- a/sandbox/OVERVIEW.md +++ b/sandbox/OVERVIEW.md @@ -614,13 +614,29 @@ the phase that resolved each one. v1 (`sandbox/` + `homeassistant/components/sandbox/` + `tests/components/sandbox/`) was removed ahead of the "shipped a stable release" condition, relying on git history for rollback. -- **`calendar` / `todo` / `weather` query-shaped RPCs.** `async_get_events` - (calendar), `todo_items` (todo), and `weather.async_forecast_*` - return server-side query results the action-call channel can't - express. The Phase 13 proxies return empty lists for these; a - separate query-shaped RPC is owed if the compat sweep ever surfaces - an integration that depends on these surfaces (it hasn't yet — see - [`BACKLOG.md`](BACKLOG.md)). +- **Query-shaped RPCs** (`calendar` / `weather` / `media_player` / + `update` / `vacuum`). Server-side query, subscription, and WS-only + mutation APIs the fire-and-forget action-call channel can't express + (`async_get_events`, `async_forecast_*`, `async_browse_media` / + `async_search_media`, `async_release_notes`, `async_get_segments`, + calendar event update/delete). These proxies now **raise + `HomeAssistantError`** via `entity.raise_not_proxied` instead of + returning empty. The catalogue + the planned request/response design + (a generic `EntityQuery` RPC for the service-less ops, reusing the + existing `call_service` `return_response` path for the rest) live in + [`docs/query-shaped-rpcs.md`](docs/query-shaped-rpcs.md) / + [`plans/plan-query-rpc.md`](plans/plan-query-rpc.md). Subscriptions + (`weather/subscribe_forecast`, `calendar/event/subscribe`) are + deferred. `todo` is now in `SANDBOX_INCOMPATIBLE_PLATFORMS` (routed to + main) because its To-do panel reads the sync `todo_items` property + that feeds `state` — it needs a pushed item-list cache, not a query. + **Caveat (`media_player.browse_media`):** even once wired, a sandboxed + player's browse will surface only its **own** sources — the + `media_source` tree it normally merges via + `media_source.async_browse_media(self.hass, …)` is empty inside the + sandbox, because `media_source` runs on main, outside the boundary. + Not a bug; closing it needs a cross-boundary hook, pairing with the + opt-in sharing work above. - **Diagnostic snapshot drift.** ~30 integrations have `__snapshots__/` files that include `entry.as_dict()` and now show `+ 'sandbox': 'built-in'`. The fix lives in those integrations' diff --git a/sandbox/docs/query-shaped-rpcs.md b/sandbox/docs/query-shaped-rpcs.md new file mode 100644 index 000000000000..7e64581fdef9 --- /dev/null +++ b/sandbox/docs/query-shaped-rpcs.md @@ -0,0 +1,102 @@ +# Query-shaped RPCs — the unproxied entity-component APIs + +> Status: **gap catalogue + interim behaviour.** The interim behaviour (raise +> `HomeAssistantError` instead of silently returning empty) is shipped; the +> request/response RPC that actually makes these work is not. Brainstorm the +> design against this list. + +## Why these don't ride the existing bridge + +The entity bridge (§8 of [`ARCHITECTURE.md`](../ARCHITECTURE.md)) is +**fire-and-forget**: a proxy entity method becomes one +`services.async_call(domain, service, target=…)` over `sandbox/call_service`. +That shape can *command* the real entity but can't **ask it a question and get +an answer back**. Every API below is a server-side query, a subscription, or a +WS-only mutation that has no service to forward through — so the proxy can't +express it, and until a request/response (and, for two of them, +subscription-shaped) RPC lands it raises via `entity.raise_not_proxied(...)` +rather than returning empty data that looks real. + +Two distinct missing primitives: + +1. **Request/response RPC** — `sandbox/entity_query` (or per-call): main sends + `{sandbox_entity_id, method, args}`, the sandbox invokes the real entity + method and returns the serialised result. Covers everything except the + subscriptions. +2. **Subscription / push RPC** — a `sandbox/entity_subscribe` + push channel for + the `*/subscribe` commands (weather forecast, calendar events) and for + pushing the `todo` item list into a proxy cache, so the sandbox can stream + updates main re-emits to the WS client. (`todo` is routed to main until this + lands — see the note below.) + +## The catalogue + +Entrypoint = what a frontend/automation actually calls on main. Entity API = +the method/property the core handler invokes on the (proxy) entity. "Forwards" +means it already works one-way via a service; everything else now raises. + +| Domain | Entrypoint (service / WS) | Entity API | Shape | Interim | +|---|---|---|---|---| +| `calendar` | `calendar.get_events` (svc, response) | `async_get_events` | request/response | **raises** | +| `calendar` | `calendar/event/subscribe` (WS) | `async_get_events` + recurrence timer | subscription | **raises** (via `async_get_events`) | +| `calendar` | `calendar/event/create` (WS) | `async_create_event` | command | forwards (`calendar.create_event` svc) | +| `calendar` | `calendar/event/update` (WS) | `async_update_event` | command (WS-only, no svc) | **raises** | +| `calendar` | `calendar/event/delete` (WS) | `async_delete_event` | command (WS-only, no svc) | **raises** | +| `todo` | *whole platform* | `todo_items` (property) | n/a | **routed to main** (see note) | +| `weather` | `weather.get_forecasts` (svc, response) | `async_forecast_{daily,hourly,twice_daily}` | request/response | **raises** | +| `weather` | `weather/subscribe_forecast` (WS) | `async_forecast_*` + listeners | subscription | **raises** (via `async_forecast_*`) | +| `media_player` | `media_player.browse_media` (svc, response) / `media_player/browse_media` (WS) | `async_browse_media` | request/response | **raises** | +| `media_player` | `media_player/search_media` (WS) | `async_search_media` | request/response | **raises** | +| `update` | `update/release_notes` (WS) | `async_release_notes` | request/response | **raises** | +| `vacuum` | `vacuum/get_segments` (WS) | `async_get_segments` | request/response | **raises** | + +### The `todo` exception — routed to main, not proxied + +`TodoListEntity.state` is `len(self.todo_items)`, so `todo_items` is read on +**every state write**, not just on a query. It can't raise (that would break +the state machine) and it can't block on a request/response query (it's a sync +property). The only honest fix is for the sandbox to **push** the item list +into a proxy cache so `todo_items` returns it synchronously — i.e. the +subscription/push primitive, which is out of scope this iteration. + +Rather than ship a proxy whose To-do panel silently shows an empty list while +looking supported, `todo` is in `SANDBOX_INCOMPATIBLE_PLATFORMS` +(`components/sandbox/const.py`) — any integration exposing a `todo` platform +routes to main, exactly like `camera`. There is no `todo` proxy. Revisit when +the push primitive lands. + +## Not in scope here (handled elsewhere) + +- **`camera`** — excluded entirely by `SANDBOX_INCOMPATIBLE_PLATFORMS` (byte + streams the channel can't ferry). +- **`todo`** — also `SANDBOX_INCOMPATIBLE_PLATFORMS` (sync-property-feeds-state + problem above; needs a push primitive, not a query). +- **`image`** — `ALWAYS_MAIN` (non-idempotent pre-dispatch work). +- **Static metadata WS commands** — `weather/convertible_units`, + `sensor/device_class_convertible_units`, `sensor/numeric_device_classes`, + `number/device_class_convertible_units`. These are stateless lookups that run + on main and never touch a sandboxed entity; nothing to proxy. + +## Caveat: `media_player.browse_media` won't include media sources + +On a normal install a media player's `async_browse_media` merges in the +**`media_source`** tree (local media, TTS-cached clips, etc.) by calling +`media_source.async_browse_media(self.hass, …)`. Inside the sandbox `self.hass` +is the private, isolated instance — `media_source` runs on **main**, outside +the sandbox boundary, so that call has nothing to resolve against. A sandboxed +player's browse therefore surfaces **only the player's own sources**; the +"Media Sources" branch will be empty for now. Closing this needs a cross- +boundary hook (the sandbox would have to call back into main's `media_source`), +which belongs with the same opt-in sharing work as the lockdown helpers — out +of scope for the query RPC. Document it where the browse proxy is wired so it +isn't mistaken for a bug. + +## Response-returning services — a second look + +`calendar.get_events`, `todo.get_items`, `weather.get_forecasts`, and +`media_player.browse_media` are registered with `SupportsResponse.ONLY`. They +dispatch to the entity method **on main** (against the proxy), so they're +covered by the request/response RPC above — but whoever designs that RPC should +confirm the service-forwarder path (`ServiceMirror`) also carries a +`ServiceResponse` back for any *integration-owned* response service, which is a +related but separate hole. diff --git a/sandbox/plans/plan-query-rpc.md b/sandbox/plans/plan-query-rpc.md new file mode 100644 index 000000000000..0f42e310af97 --- /dev/null +++ b/sandbox/plans/plan-query-rpc.md @@ -0,0 +1,207 @@ +# Plan — Query-shaped RPCs (request/response iteration) + +> Source: [`../../.claude/plans/query-rpc/interview.md`](../../.claude/plans/query-rpc/interview.md) +> · Gap catalogue: [`../docs/query-shaped-rpcs.md`](../docs/query-shaped-rpcs.md) +> · Status notes go to `sandbox/status/STATUS-plan-query-rpc.md` when work lands. + +## Goal + +Let sandbox proxy entities answer server-side **queries** they currently raise +`HomeAssistantError` for. Two mechanisms, no new subscription primitive this +iteration: + +- **Reuse the existing `call_service` + `return_response` path** for entity + methods that have a `SupportsResponse` service. +- **Add one generic `EntityQuery` RPC** for the genuinely service-less methods. + +`todo` is **out** — already routed to main via `SANDBOX_INCOMPATIBLE_PLATFORMS` +(needs a push-cache, not a query). Subscriptions (`weather/subscribe_forecast`, +`calendar/event/subscribe`) stay raising — deferred to a later push iteration. + +## Success criteria + +- [ ] `calendar.get_events`, `weather.get_forecasts`, `media_player.browse_media` + return real data through a sandboxed entity. +- [ ] `media_player/search_media`, `update/release_notes`, `vacuum/get_segments`, + `calendar/event/update`, `calendar/event/delete` work through a sandboxed + entity. +- [ ] A sandbox-side `HomeAssistantError` / `ServiceValidationError` / + `BrowseError` / `SearchError` surfaces on main as the same error shape a + local entity would raise (mirrors today's `vol.Invalid` translation). +- [ ] Channel-down / sandbox-unavailable degrades to a clean + `HomeAssistantError`, never a hang or raw decode error. +- [ ] `uv run pytest tests/components/sandbox/` + the client suite green; proto + drift guard passes; `uv run prek run` clean. + +## The verified split + +| Op | Mechanism | Return type to rebuild on main | +|---|---|---| +| `calendar.async_get_events` | service `calendar.get_events` (`return_response`) | `list[CalendarEvent]` | +| `weather.async_forecast_{daily,hourly,twice_daily}` | service `weather.get_forecasts` (`return_response`) | `list[Forecast]` (plain TypedDict — trivial) | +| `media_player.async_browse_media` | service `media_player.browse_media` (`return_response`) | `BrowseMedia` (recursive) | +| `media_player.async_search_media` | **EntityQuery** | `SearchMedia` (holds `list[BrowseMedia]`) | +| `update.async_release_notes` | **EntityQuery** | `str | None` (trivial) | +| `vacuum.async_get_segments` | **EntityQuery** | `list[Segment]` (dataclass) | +| `calendar.async_update_event` | **EntityQuery** | `None` (mutation) | +| `calendar.async_delete_event` | **EntityQuery** | `None` (mutation) | + +Rule for future ops: **service path when a `SupportsResponse` service maps to +the method; `EntityQuery` only when none exists.** + +--- + +## Phase 1 — Service-path response queries (no proto change) + +The `return_response` plumbing already exists end-to-end: +`bridge.async_call_service(..., return_response=True)` → +`_raw_call_service` sets `CallService.return_response` → +sandbox `EntryRunner._handle_call_service` runs the service with +`return_response=True` and packs `CallServiceResult.response.data` +(`entry_runner.py:122`). Only the proxy + rebuild layer is missing. + +- [ ] Add a `return_response` param to `SandboxProxyEntity._call_service` + (`entity/__init__.py:164`) — pass it through to `async_call_service`, and + when set, return `struct_to_dict(result.response.data)` if + `result.HasField("response")` else `{}`. (Today proxies ignore the + return; the value is the decoded `CallServiceResult` pb message.) +- [ ] **calendar** (`entity/calendar.py`): replace the `async_get_events` raise + with a `calendar.get_events` service forward (`return_response=True`, + `start_date_time`/`end_date_time` args) and rebuild `list[CalendarEvent]` + from the response `events` list. Add a `_calendar_event_from_dict` helper + (summary/start/end/description/location/uid/rrule/recurrence_id). +- [ ] **weather** (`entity/weather.py`): replace the three `async_forecast_*` + raises with a `weather.get_forecasts` service forward (`type=`, + `return_response=True`); the response is keyed by **sandbox** entity_id — + unwrap `response[self.description.sandbox_entity_id]["forecast"]` and + return it (Forecast is a plain TypedDict, no rebuild). +- [ ] **media_player** (`entity/media_player.py`): replace `async_browse_media` + raise with a `media_player.browse_media` service forward + (`media_content_type`/`media_content_id`, `return_response=True`) and + rebuild `BrowseMedia` from the dict (recursive over `children`). Add a + `_browse_media_from_dict` helper. + **Caveat to document at the call site (and Risk #5):** a sandboxed + player's browse will include **only its own sources** — the `media_source` + tree it normally merges via `media_source.async_browse_media(self.hass, …)` + is empty inside the sandbox, because `media_source` runs on main, outside + the sandbox boundary. Not a bug; closing it needs a cross-boundary hook + (later, with the opt-in sharing work). See the catalogue caveat. +- [ ] Confirm the service response shape on the sandbox side: `browse_media` + service returns `BrowseMedia.as_dict()`; `get_events` returns + `{"events": [...]}`; `get_forecasts` returns `{: {"forecast": [...]}}`. + Pin each in a test fixture. + +## Phase 2 — `EntityQuery` RPC primitive + +Mirror the `call_service` pattern exactly (proto → codec registry → bridge +sender + error translation → sandbox handler). + +- [ ] **proto** (`sandbox/proto/sandbox.proto`): add + ``` + message EntityQuery { + string sandbox_entity_id = 1; + string method = 2; // e.g. "async_search_media" + google.protobuf.Struct args = 3; // kwargs, dynamic + optional string context_id = 4; // same wire-safe id rule as CallService + } + message EntityQueryResult { + google.protobuf.Struct result = 1; // wrapped: {"value": } + } + ``` + Wrap the return in a `{"value": …}` struct so scalar/list/None returns are + all representable (Struct's top level must be an object). +- [ ] Regenerate gencode: `bash sandbox/proto/generate.sh` (isolated venv, writes + both `_proto` mirrors). Verify `sandbox/proto/check_drift.sh` passes. +- [ ] **constants**: add `MSG_ENTITY_QUERY = "sandbox/entity_query"` to both + `homeassistant/components/sandbox/protocol.py` and + `sandbox/hass_client/hass_client/protocol.py`. +- [ ] **registry**: add `"sandbox/entity_query": (pb.EntityQuery, pb.EntityQueryResult)` + to `messages.REGISTRY` in **both** mirrors (`messages.py`). +- [ ] **bridge sender** (`bridge.py`): add + `async_entity_query(*, sandbox_entity_id, method, args, context) -> Any` + next to `async_call_service`. Build `pb.EntityQuery`, `channel.call`, + translate errors through the existing `_translate_remote_error` / + `ChannelClosedError` paths, and return `struct_to_dict(result.result)["value"]`. +- [ ] **sandbox handler**: register `MSG_ENTITY_QUERY` in `EntryRunner._wire` + (`entry_runner.py:49`). Handler resolves the entity from the private hass + by `sandbox_entity_id`, `getattr`s `method`, `await`s it with + `struct_to_dict(args)`, wraps `{"value": _serialise(return)}` into + `EntityQueryResult`. Raised exceptions propagate as channel error frames — + reuse the existing `error_data` packing so `vol.Invalid`/HA errors rebuild + on main (confirm `BrowseError`/`SearchError` map to `HomeAssistantError`). +- [ ] **proxy helper** (`entity/__init__.py`): add + `async def _entity_query(self, method, **args)` calling + `self._bridge.async_entity_query(...)` with `self._context`. + +## Phase 3 — Wire the service-less ops onto `EntityQuery` + +Replace each `raise_not_proxied(...)` with an `_entity_query` call + rebuild: + +- [ ] **media_player.async_search_media** → `_entity_query("async_search_media", + query=)`; rebuild `SearchMedia` (reuse the + `_browse_media_from_dict` helper for its `result` list). +- [ ] **update.async_release_notes** → `_entity_query("async_release_notes")`; + return the str/None directly. +- [ ] **vacuum.async_get_segments** → `_entity_query("async_get_segments")`; + rebuild `list[Segment]` (dataclass). +- [ ] **calendar.async_update_event / async_delete_event** → `_entity_query(...)` + forwarding uid/event/recurrence args; ignore the `None` result. +- [ ] Sandbox-side `_serialise` must handle each return: `SearchMedia.as_dict()`, + `Segment` (dataclass → `dataclasses.asdict`), `BrowseMedia.as_dict()`. + +## Phase 4 — Serialization fidelity + tests + +- [ ] One serializer/deserializer per rich type, with a round-trip unit test: + `BrowseMedia` (recursive `children`, `thumbnail`, `media_class`), + `CalendarEvent` (rrule/recurrence_id/all-day date vs datetime), + `SearchMedia`, `Segment`. Forecast + release_notes are plain → assert + pass-through only. +- [ ] Extend `tests/components/sandbox/test_domain_proxies.py` (or a new + `test_entity_query.py`) with a query case per op: stub the sandbox-side + entity method, assert the proxy returns the rebuilt typed object. +- [ ] Error-path tests: sandbox method raises `ServiceValidationError` → + proxy raises the translated error; channel closed → `HomeAssistantError`. +- [ ] Client-side test for the `EntityQuery` handler + (`sandbox/hass_client/`): unknown entity_id, unknown method, method raises. + +## Verification + +```bash +bash sandbox/proto/check_drift.sh +uv run pytest tests/components/sandbox/ --no-cov -q +uv run pytest sandbox/hass_client/ -q +uv run prek run --files +``` + +## Risks / open questions (self-check) + +1. **Recursive `BrowseMedia` size.** A media tree can be large; one Struct + round-trip per browse is fine (it's user-initiated, not hot-path), but note + it — no coalescing needed here. +2. **`as_dict()` vs constructor asymmetry.** `BrowseMedia.as_dict()` is shaped + for the frontend (e.g. `children_media_class`, `thumbnail`), not for + `BrowseMedia(**d)`. The rebuild helper must map fields explicitly, not + splat. Same caution for `CalendarEvent` (dates serialise to ISO strings). + This is the highest-effort, highest-risk part — validate with round-trip + tests first (Phase 4 before Phase 1/3 rebuild code if needed). +3. **Why generic `EntityQuery` over typed messages?** Decided in interview: + far fewer proto messages; the cost is serialization lives in hand-written + `_serialise`/rebuild helpers rather than the proto schema. Acceptable for a + handful of ops; revisit if the surface grows. +4. **Does the service path double-validate?** The sandbox re-runs the full + `calendar.get_events`/`browse_media` service (schema + handler) against the + real entity — that's the point (fidelity), not a bug. Confirm no main-side + pre-validation rejects before forwarding. +5. **`browse_media` loses the media-source tree.** A sandboxed player's browse + shows only its own sources; the `media_source`-backed "Media Sources" branch + is empty because `media_source` lives on main, not in the sandbox's private + hass. Known limitation, documented at the call site — needs a cross-boundary + hook to fix (out of scope, pairs with opt-in sharing). + +## Out of scope (explicit) + +- Subscription/push RPC (`weather/subscribe_forecast`, `calendar/event/subscribe`, + and the eventual `todo` item-list push that would un-block `todo`). +- Coalescing same-tick queries (mirrors the `call_service` future-opt note). +- Integration-owned `SupportsResponse` services through `ServiceMirror` — the + related response-carry hole flagged in the catalogue; separate task.