| **0 — Snapshot** | Import-only tooling (this skeleton). Pull `/config` + `.storage` from each instance into the repo, read-only. No deploy, no write path back to HA. |
| **1 — Repo + CC** | Repo is the reviewable source of truth. Changes are authored in the repo (by a human or Claude Code) and pushed manually via the docker-exec / api adapters described below. Deploy has a hard drift-abort (see Sync model). **Partially built**: `scripts/ha/deploy.sh` covers the `api` adapter's automations/scripts/scenes scope; `docker-exec` deploy and dashboards/helpers writes are still open. |
| **2 — MCP read-only** | Expose HA state (entities, areas, config) to agents via an MCP server in read-only mode — either a self-hosted MCP or `hass-mcp` — to let agents reason about the live instance without touching import/deploy paths. Undecided which (see Open questions). |
| **3 — Agents** | Agents propose changes (automations, scripts, scenes) through the same reviewable repo path used by humans; the human-in-the-loop approval flow from `services/control-plane/` (pending → approved → executed) governs anything destructive. Telegram becomes a first-class interface alongside CC. |
Each phase is a hard gate: no phase-N tooling depends on phase-(N+1) existing.
## Scope
Full `/config` tree per instance, plus a curated export of `.storage/*` JSON
files that are meaningfully version-controllable (registries, dashboards,
helpers) — not secrets, tokens, or runtime databases. See `.gitignore` below
for the exact exclusion list.
## Canonical format
All YAML committed to the repo passes through one normalization function
(`scripts/ha/lib/normalize.py`), used by every import path with no
per-caller variation:
- block style (no flow/inline collections)
- fixed indent width 2
- keys sorted (stable diffs)
- unlimited line width (no wrapping)
- UTF-8, no BOM
`.storage/*` files are JSON on disk in HA; on import they are parsed as JSON
and re-emitted through the same YAML normalizer so the whole repo — config
and storage-export alike — has one diff format.
## Deploy path: adapter per instance
There is no single deploy mechanism — each HA instance gets an adapter
behind a common interface (`import.sh`/eventual `deploy.sh <instance>`):
| `ken` (RPi4, HAOS, LAN `192.168.31.7:8123`) | **api** | Canonical home instance since the 2026-07-22 cutover (see Incident log). HAOS has no SSH access, so there is no `docker exec`/filesystem path — only the HA REST/websocket API is reachable. Full `/config` import is deferred until an alternative access path exists; for now the api adapter's import scope is limited to what the API exposes: automations, scripts, scenes, dashboards. |
| `ken-legacy` (piha, container `homeassistant5`) | **docker-exec over SSH** (archive-only) | Pre-migration container instance, superseded by `ken` at 31.7 (see Incident log) — same container filesystem access as the old `ken` entry (`ssh oskar@piha "docker exec homeassistant5 ..."`). Import only, for historical reference; never a deploy target. |
| `chelsty-ha` | **api** | Reachable over Tailscale at `100.70.180.90:8123` (confirmed working path — `services/ha-diag-agent/DEPLOY.md` already curls this for health checks). Config-as-code deploy will reuse the same reachability, calling the HA REST/websocket API rather than shelling into the container. |
**Open**: a `file` adapter (direct bind-mount / SSH `rsync` to the config
directory, bypassing `docker exec`) is worth revisiting once SSH access to
the `chelsty-ha` VM itself is verified — see Open questions.
## Sync model: bidirectional, asymmetric safety
- **Deploy** (repo → instance): hard **drift-abort**. Before writing, the
adapter re-imports the instance's current state and diffs it against the
last-known-imported snapshot committed in the repo. Any unexpected
difference aborts the deploy with a non-zero exit and a diff printed —
never silently overwrites live drift. This applies unconditionally on
PIHA (`ken`); untested/undecided whether it should be relaxed for
`chelsty-ha` given its intermittent LTE uplink (see Open questions).
- **Import** (instance → repo): the reverse direction is intentionally
permissive. Running `import.sh <instance>` on a dev workstation pulls
current state and, if it differs from the last commit, the operator
commits it under a conventional message: `drift(<instance>): <summary>`.
This is how out-of-band UI changes (made directly in the HA web UI) get
captured back into git history instead of being silently overwritten by
the next deploy.
## Validation gate
`check_config` (HA's built-in config validator, invoked via the running