sandbox: A3 — document the catalog-provider HACS contract

Add sandbox/docs/catalog-provider-contract.md describing the display-only
picker catalog hook: the discoverability gap it closes, the
async_register_sandbox_catalog_provider API, and the contract — separate
from the sha-pinned source resolver, name load-bearing, title_translations
optional, no validation, display-only scope, and how it complements the
Phase B live RPC for the cold picker case.

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 4c2feccd78
commit 266bbbe5e0
+99
View File
@@ -0,0 +1,99 @@
# Catalog provider — picker discoverability for sandbox-only customs
> **Current design (2026-06-05, plan-translation-forwarding Phase A).** Core
> exposes a **display-only** catalog hook so a custom integration whose code
> lives only in a sandbox — never on main's `<config>/custom_components` disk —
> can be listed and named in the add-integration picker **without spawning a
> sandbox**. HACS (or any distribution mechanism) fills it. The hook is
> deliberately separate from the security-critical integration-source resolver.
## The gap
The add-integration picker is built from `integration/descriptions`
(`async_get_integration_descriptions`, `homeassistant/loader.py`), whose custom
half is a scan of `<config>/custom_components` on **main's** disk. Under the
stateless-sandbox model a custom integration's code is fetched at `entry_setup`
into the sandbox and is **never on main's disk**, so:
- it has **no picker row** (the disk scan never sees it), and
- even if a row existed, the `title` translation category has nothing to load —
the `integration.name` fallback in
`_async_get_component_strings` (`homeassistant/helpers/translation.py`) needs a
loaded `Integration`, which main cannot build for code it doesn't have.
This is a **discoverability** gap, of which `title` is a subset. Closing it
needs only a tiny static descriptor per domain — not a sandbox spawn (the picker
never loads `config`/`selector`, only `title`; those load per-flow once the user
starts adding the integration, where the Phase B live RPC handles them).
## The hook
```python
from homeassistant.components.sandbox.catalog import (
SandboxIntegrationDescriptor,
async_register_sandbox_catalog_provider,
)
def _catalog() -> list[SandboxIntegrationDescriptor]:
return [
{
"domain": "my_custom",
"name": "My Custom Integration", # load-bearing
"config_flow": True,
"integration_type": "integration", # or "helper"
"iot_class": "cloud_polling",
"single_config_entry": False,
# optional; absent -> picker degrades to `name`
"title_translations": {"en": "My Custom Integration"},
}
]
unregister = async_register_sandbox_catalog_provider(hass, _catalog)
```
`async_register_sandbox_catalog_provider` is re-exported from the sandbox
component (parallel to `async_register_sandbox_source_resolver` in
`sandbox/sources.py`) for a single HACS-facing namespace; the registry itself
lives in `homeassistant.loader` because core — not the sandbox component —
consumes it (`async_get_integration_descriptions` and the translation `title`
fallback). Providers are consulted in registration order; the first to claim a
domain wins. The returned callback unregisters.
## Contract
- **Separate from the source resolver.** The source resolver
(`IntegrationSourceDict`, `sandbox/sources.py`) is lazy, per-domain, and
**security-critical**: it pins `ref` to an exact commit sha and core does no
network I/O, so it trusts that pin. The catalog is **eager, enumerable, and
cosmetic**. Fusing them would drag display strings through the sha-validation
path and force the security-critical resolver to also be a full listing API.
- **`name` is load-bearing.** It feeds both the picker row
(`integration.name || domainToName(...)` in the frontend) and the `title`
fallback. A descriptor without a usable `name` falls back to a prettified
domain — acceptable, but worse UX.
- **`title_translations` is optional.** HACS reliably knows the manifest `name`
(it parses `manifest.json` to validate installs) but may **not** have the
integration's `translations/<lang>.json` indexed — those live in the repo
tarball, fetched only at `entry_setup`. When `title_translations[lang]` is
absent the picker degrades to `name` (the same fallback chain main already
uses). A localized title is a nice-to-have, not a requirement.
- **No validation.** Unlike `ref` (sha-pinned, security-critical), a wrong or
missing `name` is cosmetic, so core does **no** strict validation of catalog
descriptors. A domain that an on-disk scan also finds keeps the on-disk
metadata — the disk scan wins a collision.
- **Display-only scope.** The catalog carries picker metadata, nothing more. It
is intentionally **not** the broader "stateless-custom discovery" feature
(config-flow allow-listing, schema, etc.); those remain out of scope.
## Relationship to the live path (Phase B)
Phase B already forwards a *running* sandboxed integration's strings over the
`sandbox/get_translations` RPC, routed by `entry.sandbox` / the in-progress
`SandboxFlowProxy`. The catalog covers the **cold** picker case where there is
no entry and no running flow — so no group to route to — and the live RPC would
return nothing. The two are complementary: catalog for the cold list + name,
RPC for everything once a flow starts or an entry is loaded.