sandbox/docs: catalogue query-shaped RPC gap + request/response plan

Document the unproxied query/subscribe/WS-only entity APIs, their interim
raise behaviour, and the two missing primitives (request/response +
subscription RPC) in docs/query-shaped-rpcs.md. Add the implementation
plan (plan-query-rpc.md): a generic EntityQuery RPC for the service-less
ops + reuse of the existing call_service return_response path for ops
that have a SupportsResponse service. Note the media_player.browse_media
caveat (no media_source tree inside the sandbox). Cross-reference from
ARCHITECTURE/OVERVIEW/CLAUDE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paulus Schoutsen
2026-07-07 15:12:23 -04:00
co-authored by Claude Opus 4.8
parent 932b54cc0e
commit 5b0a73ebb5
5 changed files with 347 additions and 15 deletions
+3 -3
View File
@@ -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 (`<domain>:<unique_id>`)
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).
---
+12 -5
View File
@@ -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
+23 -7
View File
@@ -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'
+102
View File
@@ -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.
+207
View File
@@ -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=<kind>`,
`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 `{<entity_id>: {"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": <return>}
}
```
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=<SearchMediaQuery as dict>)`; 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 <changed>
```
## 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.