Architecture Decision Records¶
Architecture Decision Records (ADRs) capture the consequential design choices behind OpenCCU-Loom — the decision, its context, and its consequences — so future readers can understand why the code looks the way it does.
Who this page is for
Contributors and developers who need the rationale behind a design choice. ADRs are immutable once landed: a superseded decision gets a new ADR rather than an edit to the old one.
The table below catalogues every ADR. Each entry links to the record itself — except the Matter wire-stack decisions, which moved to go-fabric with the code they describe. Their numbers are kept here as forwarding pages so existing links and bookmarks keep resolving, and those numbers are never reused: the next ADR continues from the highest number in this table.
| # | Title | Status | Summary |
|---|---|---|---|
| 0001 | License: MIT | superseded by 0085 | The source was licensed MIT up to 0.91.0. Superseded by ADR 0085. |
| 0002 | Multi-CCU as a first-class feature | accepted | One daemon serves many CCUs, scoped by central_name, from 0.1.0. |
| 0003 | Embed openccu-data metadata artifacts | accepted | Translations, easymodes, and profiles are embedded from openccu-data. |
| 0004 | Python decorators ↔ Go cross-cutting | accepted | How decorator-based concerns from the Python reference map to Go. |
| 0005 | Visibility as outbound filter | accepted | Visibility is applied as an outbound filter on REST and MQTT. |
| 0006 | Naming conventions for REST + MQTT | accepted | Shared naming rules across the REST and MQTT surfaces. |
| 0007 | Strong model: Source interface | accepted | A single Source interface backs both reads and writes. |
| 0008 | AggregatedState default-flip | accepted; topology part superseded by 0011 | Flips the AggregatedState default and removes the legacy path. |
| 0009 | Service-method command topics | accepted | Service-method command topics in HA Discovery. |
| 0010 | Discovery payload from the model | accepted | HA-Discovery payload construction moves into the model. |
| 0011 | MQTT topic & payload architecture | accepted | The topic and payload structure for the MQTT plane. |
| 0012 | Matter bridge: pure-Go | accepted | The Matter bridge is implemented in pure Go, no CGo SDK. |
| 0013 | Matter wire-protocol design rules | moved to go-fabric | Moved to go-fabric — the record is maintained as go-fabric ADR 0001. This page forwards. |
| 0014 | Parameter ignore / un-ignore mechanics | accepted | How parameters are ignored and un-ignored. |
| 0015 | Split Ignored from NoCreate | accepted | Separates Ignored from NoCreate in DataPointUsage. |
| 0016 | Custom-DP-aware UI rendering | accepted | UI rendering is aware of custom data points. |
| 0017 | Logging and diagnostics | accepted | The logging and diagnostics model. |
| 0018 | Health tracker parity | accepted | The health tracker mirrors aiohomematic. |
| 0019 | Persistent VALUES cache | accepted | A persistent VALUES cache with a wire-DP lifecycle. |
| 0020 | External-client wire contract | accepted | The wire contract external clients depend on. |
| 0021 | mDNS self-advertisement | accepted; TXT part superseded by 0058 | mDNS self-advertisement for LAN auto-discovery. |
| 0022 | WebSocket resume cursor + kind | accepted | The WebSocket resume cursor and envelope kind discriminator. |
| 0023 | HmIP-Wired is a ProductGroup | accepted | HmIP-Wired is modeled as a ProductGroup, not an interface. |
| 0024 | Instance vs CCU identity | accepted; InitInterfaceID formula superseded by 0060 | Daemon-instance vs CCU identity, and the two interface ids. |
| 0025 | MCP server as a north-bound adapter | accepted | The MCP server is a north-bound adapter. |
| 0026 | MCP dev-mode | deferred | A proposed build-tag-gated MCP introspection surface; not built. |
| 0027 | Encrypt config secrets at rest | accepted | Config secrets are encrypted at rest. |
| 0028 | Contract digest & version guard | accepted | A contract digest and version guard couple API-schema changes to the types-repo release. |
| 0029 | Tier-model teardown for Unit.Stop | accepted | Unit.Stop tears down subsystems in tiered order. |
| 0030 | Event-bus dispatch striping: rejected | rejected | Per-central isolation already meets the goal; dispatch striping is rejected. |
| 0031 | IM opcode dispatch seam | moved to go-fabric | Moved to go-fabric — the record is maintained as go-fabric ADR 0002. This page forwards. |
| 0032 | Sigma resumption extraction | moved to go-fabric | Moved to go-fabric — the record is maintained as go-fabric ADR 0003. This page forwards. |
| 0033 | Groups cluster stays a stub | moved to go-fabric | Moved to go-fabric — the record is maintained as go-fabric ADR 0004. This page forwards. |
| 0034 | Adapter package taxonomy | accepted | internal/central/adapter stays one package with a documented taxonomy. |
| 0035 | Hub refresh-set extraction | accepted | The refresh-coordination sub-component is extracted from HubCoordinator. |
| 0036 | Matter Bridge decomposition | moved to go-fabric | Moved to go-fabric — the record is maintained as go-fabric ADR 0005. This page forwards. |
| 0037 | OTLP span exporter | accepted | A pluggable span exporter ships a lean OTLP/HTTP exporter, not the OTel-gRPC SDK. |
| 0038 | Cross-stack CI gate | accepted | The cross-stack model-snapshot parity gate runs in nightly CI. |
| 0039 | Subscribe dispatch seam | moved to go-fabric | Moved to go-fabric — the record is maintained as go-fabric ADR 0006. This page forwards. |
| 0040 | Measurement history | accepted | Measurement history is stored in embedded SQLite with an opt-in push exporter. |
| 0041 | Persist auth sessions | accepted | Auth sessions persist in SQLite as a save-through cache. |
| 0042 | Clear CCU cache and re-pull | accepted | Clearing CCU-derivable caches and re-pulling is a first-class operation. |
| 0043 | CCU as an authentication provider | accepted | Login can be delegated to a CCU's own user database. |
| 0044 | Single-port onboarding + HA Ingress auth | accepted | Onboarding runs on a single port with HA Ingress auth passthrough. |
| 0045 | Login + onboarding into the SPA | accepted | Login and first-run onboarding live in the Svelte SPA. |
| 0046 | SSDP CCU discovery | accepted | CCUs are discovered on the LAN via active SSDP/UPnP. |
| 0047 | North-bound bridge registry | accepted | North-bound bridges are Services owned by a Registry. |
| 0048 | chip-tool ↔ godevccu send/receive matrix | moved to go-fabric | Moved to go-fabric — the record is maintained as go-fabric ADR 0007. This page forwards. |
| 0049 | Matter one endpoint per device | accepted | Matter exposes one endpoint per physical device by default. |
| 0050 | MQTT transport → shared go-mqtt module | accepted | The in-tree MQTT transport is extracted into the external shared go-mqtt module. |
| 0051 | North-bound authorization model | accepted | Role-based MinRole gating unified across REST + WS, plus backup-at-rest sealing. |
| 0052 | Daemon-level alarm MQTT topics | accepted | The alarm engine publishes panel state/commands on daemon-level topics, not per-central ones. |
| 0053 | CCU metadata via go-openccu-data | accepted | Embedded OCCU extracts come from the versioned go-openccu-data module instead of a hand-synced copy. |
| 0054 | Remote ingress proxy add-on | accepted | OpenCCU-Loom Remote: an HA Ingress panel for remote instances via token injection, instead of the local-only Ingress passthrough. |
| 0055 | Heating groups via the CCU jpages proxy | accepted | Heating-group administration goes through the CCU's own jpages endpoints, the only interface that drives the member roster. |
| 0056 | Room areas, and the zone/area naming split | accepted | Operator-defined areas group CCU rooms one level up; the alarm partition is renamed "zone" to free the word. |
| 0057 | CCU add-on self-update | accepted | The CCU/OpenCCU add-on updates itself through the firmware's own install_addon path, with no WebUI round trip. |
| 0058 | mDNS TXT carries the configured CCU serials | accepted | Partially supersedes ADR 0021: the TXT record advertises the configured serials so clients can match before authenticating. |
| 0059 | The Security & Safety MQTT plane | accepted | Extends ADR 0052: hazard classes aggregate across centrals, so they get daemon-level topics too. |
| 0060 | loom-prefixed CCU-facing interface ids | accepted | Partially supersedes ADR 0024: the wire-boundary InitInterfaceID carries a loom prefix and drops the repeated central name. |
| 0061 | The migration Down path is unsupported | accepted | goose Down blocks exist to satisfy the tool; they are destructive and must never run in production. |
| 0062 | Suppression reasons are recomputed, not recorded | accepted | Extends ADR 0015: the Ignored mark says that a parameter is hidden, never which rule hid it, so the reason is re-derived from the same rule sets. |
| 0063 | Device profiles are maintained here, not generated | accepted | script/generate_profiles.py is removed and the catalogue becomes ordinary hand-maintained source; every deviation from the fork point is recorded in notes/parity/by_design.md. |
| 0064 | A garage drive projects as a Matter Closure, not a WindowCovering | accepted | Refines ADR 0049: the drive carries ClosureControl with named stops, so the WindowCovering projection and its lift-percentage machinery are removed. |
| 0065 | The composition root states its wiring, so a machine can check it | accepted | Each wire* function declares its seam into a manifest the daemon builds at start-up, turning "is X wired, and does it run before Y" from an approximate question into an exact one. |
| 0066 | Relicense to AGPL-3.0-only with a commercial exception | rejected | Rejected (2026-08-28). AGPL plus a written exception would have made monetisation ask first; the project stayed MIT, and no CLA obligation arose. Revisited by ADR 0085. |
| 0067 | The north surface is a model API, not an HA entity projection | accepted | The REST/WS contract stays Home-Assistant-agnostic and the entity projection stays in the Python consumer; the MQTT plane keeps its own projection and is explicitly not a precedent. Additive model fields remain welcome — the line is drawn at descriptors. |
| 0068 | What unique_id promises, and it differs per plane | accepted | Changeable on REST/WS with a documented transition and an old key derivable from the payload; changeable on MQTT discovery too, but only as a documented break with a swept orphan, since Home Assistant has no migration path there. A generic previous_unique_id was designed and rejected. Amended 2026-08-30. |
| 0069 | The master-profile surface reads a link-paramset archive | accepted | The profiles/ archive is link-paramset data: the generator reads the firmware's LINK easymode tree and skips its MASTER tree, and the reference writes it with the peer channel address as the paramset key. master_profiles.* is a link surface under a wrong name, and apply writes link-derived values into MASTER — inert only because the lookup never matches. masterprofile is deleted in favour of linkprofile. |
| 0070 | Extract the HA discovery model into a shared module | closed | Six projects carry their own copy of the MQTT/HA-discovery layer and the copies have drifted into real defects. Two modules are extracted: go-hamqtt (model, device-based discovery bundle, publisher runtime) and go-ha-catalog (the HA vocabulary, generated from the core checkout). This daemon migrates first, before any bridge — it is both the architectural source and the hardest consumer. Extends 0050. An amendment withdraws the unique-id re-key for this daemon, so 0068's guarantee holds: no entity is re-created. A second amendment records the bundle migration measured against a live instance: the registry entry survives, but only if the per-entity config is retracted before the bundle is published — the other order is refused with nothing but a log line. Closed 2026-09-14: all nine phases shipped — the closing section carries a per-phase outcome table, phase 9's deliberate decline of the device bundle (two UniFi consoles cannot be told apart), an erratum showing the LOC table understates every bridge row by 1.6x to 2.3x, and the two findings the fan-out produced that belong to no single repository. |
| 0071 | South profiles: one south-bound strategy per central | accepted | A central names its system type (ccu, openccu-lite, auto), which selects a south profile supplying readiness, events, hub bring-up, liveness, features and management ports; everything downstream of a source stays shared. The type is compared only in southProfileFor, and a guard enforces it. The occulited client is a transport package; the daemon stays MIT as a network client of a documented API. |
| 0072 | openccu-lite pushes over occulited's event stream | accepted | One server-sent-event stream per central feeds the shared callback handlers; heartbeats stamp liveness, interface state drives connection loss, and a reconnect or resync reconciles devices and values. "Every interface pushes" replaces "every interface supports push callbacks". |
| 0073 | A backend-neutral taxonomy for rooms, functions and other enums | accepted | Rooms and functions are trees of nodes named by path; names stay names on every existing field, paths are additive, an ambiguous name is refused, areas stay name-keyed. The room/function create answer's id is corrected to the integer it always was (REST API 12.0.0). |
| 0074 | Per-central features and what "absent" means | accepted | Each central carries a feature set with a reason for every absent key (not supported, missing scope, not ready). REST answers 422 feature_unavailable, MQTT does not declare, the SPA hides with the reason, MCP names the central. Refusing ports wrap the legacy sentinels. |
| 0075 | Credentials for token-based systems | accepted | api_token is a sealed, masked secret; TLS is pinned to a compared fingerprint; client pairing keeps the approved token in the daemon, and a central takes it by pairing id — no client ever receives it. |
| 0076 | Client token pairing mirrors the occulited protocol | accepted | External clients obtain a token by commit-reveal code pairing: both sides derive six digits, the administrator approves by typing them, admin is never pairable, and the approved token reaches the asking client exactly once. |
| 0077 | The add-on token onboards the local openccu-lite box | accepted | On a box that minted this add-on an API token, a first boot with no centrals adopts the local system by itself: the token is read from the file per request (rotation-safe api_token_file), the hostname and interfaces come from the box, and pairing remains only for backup/power and remote clients. |
| 0078 | The box's web server serves the Config UI (lite ingress) | accepted | The add-on ships a validated lighttpd fragment: /addons/loom/ is proxied to the loopback with the prefix stripped, so TLS and the operator's certificate terminate at the box, the shell lists the UI, and port 8119 can stay closed. Certificate reuse on the direct port stays opt-in (server.pem as combined cert+key pair). CCU3/OpenCCU ingress is a non-goal: their /etc/config/lighttpd/*.conf include exists but is unvalidated and carries no session gate, unlike occulited's. |
| 0079 | Box-shell single sign-on over the lite ingress | accepted | Accept the gate's X-Occulite-Session as a daemon identity by live verification against the onboarded box (GET /api/auth/v1/state) — never by perimeter trust. Fail-closed role mapping (box admin→admin, user→operator), SSO resolver last in the first-wins chain, packaged-on for the lite add-on only. ReGa-session reuse on CCU3/OpenCCU is rejected: no stripping gate, no verification API; ADR 0043 covers CCU accounts. |
| 0080 | Box API tokens through the lite gate | accepted | A box API token the gate accepted for the add-on (addon:<id> scope, openccu-lite ≥ 1.0.0-dev.36) is a daemon identity by live verification against the box, scheme occulite-token. The daemon re-checks the add-on's own scope (id read from the box's token-file name); Full access → admin, add-on scope → operator. One pairing at the box replaces a stored box password. |
| 0081 | The daemon describes its deployment and its login paths | accepted | One resolved deployment kind (lite-addon, ccu-addon, ha-addon, standalone), declared by the packaging in OPENCCU_LOOM_DEPLOYMENT and refined on a lite box from host facts. Published as deployment on GET /info, with one auth.* capability token per login path; the mDNS TXT record carries the short form (deploy, ingress, auth, a true tls) for clients that cannot read /info before login. The HA Ingress passthrough defaults on only for ha-addon. |
| 0082 | Device onboarding is the daemon's own, on every system | accepted | delay_new_device_creation defaults on and is an ordinary setting: a newly-paired device waits to be accepted (then built) and released (then published to MQTT, Matter and webhooks), on a CCU, an OpenCCU and an openccu-lite box alike. Devices the daemon already knows are never held, so an upgrade publishes what it published before. The CCU inbox is an input, not the surface. |
| 0083 | One topic convention for all six projects: mqtt-smarthome 2.0 (accepted; amended 2026-10-06 with the implementation's findings) | accepted | openccu-loom and the five go-*2mqtt bridges move, in one release wave, to <name>/<function>/<item> with connected (0/1/2), status, set, info, meta and maintenance; every status item is {val, ts, lc} with enum tokens and JSON booleans. Built once in go-hamqtt; unique_id and discovery identities do not change (ADR 0068), old retained topics are swept on start. Supersedes the topic-schema half of ADR 0070's clean break and ADR 0011's topic hierarchy. The amendment records the exceptions found in the code: the empty-payload restart, the JSON-schema light kept in Home Assistant's shape, the sweep's filters scoped to the old trees. |
| 0084 | Matter: the profiles move onto go-fabric's device layer, the bring-up onto its node facade (accepted 2026-10-12) | accepted | Three phases, each live-verified against Apple Home: (1) the assembler constructs every endpoint through device.New<Type> with the profiles' servers as WithServer, device-type validation becomes a guard, bridged NodeLabels persist, wire shape unchanged; (2) the five hand-written OnOff servers become cluster/onoff.Server with a CCU-writing Sink, the timed-off engine and the LT atomics are deleted; (3) startMatterBridge becomes node.New once go-fabric's facade exposes the host hooks this daemon needs (fabric and commissioning observers, per-window PASE credentials, TimeSync opt-in); the facade's window semantics are adopted then. WindowCovering and Thermostat stay hand-written. |
| 0085 | Apache License 2.0 and a contributor licence agreement (accepted 2026-10-15) | accepted | Supersedes ADR 0001 and revisits ADR 0066: the source moves to Apache-2.0 from the next release — the licence of matter.js, connectedhomeip and go-fabric, with its patent grant, for the Matter-side code this daemon carries — and contributions come under CLA.md so the project can be licensed as a whole later; 0.91.0 and earlier stay MIT, the DCO stays, TestLicenseHeaderOnEveryGoFile guards the header. |
| 0086 | The Matter pages and their API come from go-fabric-ui (accepted 2026-10-25) | accepted | The Matter view's six pages are @sukramj/fabric-ui's node page family and the routes are fabricui.Mount under /api/v1/node (REST 14.0.0, the /api/v1/matter/* routes removed without aliases); the module's contract is copied into openapi.yaml and held to the pinned version by a guard. loom keeps the exposure provider (which CCU channels are exposable, how they are named), feeds the node's events onto the WebSocket hub as node.* (wsapi 2.0, matter.* removed) instead of opening the handler's SSE stream, and keeps its own MCP / health status seam. |
| 0087 | The catalogue declares the shape, the CCU confirms the channels | accepted | A device profile keeps naming candidate channels and also declares which channel types each role accepts; the materializer binds a channel only if the device has it and its type is accepted, and a guard runs the catalogue against every fixture. The catalogue stays hand-maintained (ADR 0063). |
Related reading¶
- Architecture — how these decisions show up in the package layout.
- Matter parity contract — the host-side boundary. The projection decisions that stayed here are 0012 (cluster subset), 0021 (the daemon's own mDNS), 0049 (one endpoint per device) and 0064 (garage as Closure); the wire-side rules live in go-fabric.